@objectql/driver-mongo 4.0.1 → 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,8 +1,7 @@
1
- import { Data, System } from '@objectstack/spec';
1
+ import { Data, Driver as DriverSpec } 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 = DriverSpec.DriverInterface;
6
5
  /**
7
6
  * ObjectQL
8
7
  * Copyright (c) 2026-present ObjectStack Inc.
@@ -150,12 +149,72 @@ export class MongoDriver implements Driver {
150
149
  }
151
150
 
152
151
  private mapFilters(filters: any): Filter<any> {
153
- 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 {};
154
161
 
155
162
  const result = this.buildFilterConditions(filters);
156
163
  return result;
157
164
  }
158
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
+
159
218
  /**
160
219
  * Build MongoDB filter conditions from ObjectQL filter array.
161
220
  * Supports nested filter groups and logical operators (AND/OR).
@@ -258,68 +317,45 @@ export class MongoDriver implements Driver {
258
317
  * QueryAST sort is array of {field, order}, while UnifiedQuery is array of [field, order].
259
318
  * QueryAST uses 'aggregations', while legacy uses 'aggregate'.
260
319
  */
261
- private normalizeQuery(query: any): any {
262
- if (!query) return {};
263
-
264
- const normalized: any = { ...query };
320
+ async find(objectName: string, query: any, options?: any): Promise<any[]> {
321
+ const collection = await this.getCollection(objectName);
265
322
 
266
- // Normalize limit/top
267
- if (normalized.top !== undefined && normalized.limit === undefined) {
268
- normalized.limit = normalized.top;
269
- }
323
+ // Handle both new format (where) and legacy format (filters)
324
+ const filterCondition = query.where || query.filters;
325
+ const filter = this.mapFilters(filterCondition);
270
326
 
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
- }
327
+ const findOptions: FindOptions = {};
280
328
 
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
- }
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;
293
332
 
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);
333
+ if (offsetValue !== undefined) findOptions.skip = offsetValue;
334
+ if (limitValue !== undefined) findOptions.limit = limitValue;
301
335
 
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 }
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)) {
307
339
  findOptions.sort = {};
308
- 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';
309
344
  // Map both 'id' and '_id' to '_id' for backward compatibility
310
345
  const dbField = (field === 'id' || field === '_id') ? '_id' : field;
311
346
  (findOptions.sort as any)[dbField] = order === 'desc' ? -1 : 1;
312
347
  }
313
348
  }
314
- if (normalizedQuery.fields && normalizedQuery.fields.length > 0) {
349
+
350
+ if (query.fields && query.fields.length > 0) {
315
351
  findOptions.projection = {};
316
- for (const field of normalizedQuery.fields) {
352
+ for (const field of query.fields) {
317
353
  // Map both 'id' and '_id' to '_id' for backward compatibility
318
354
  const dbField = (field === 'id' || field === '_id') ? '_id' : field;
319
355
  (findOptions.projection as any)[dbField] = 1;
320
356
  }
321
357
  // Explicitly exclude _id if 'id' is not in the requested fields
322
- const hasIdField = normalizedQuery.fields.some((f: string) => f === 'id' || f === '_id');
358
+ const hasIdField = query.fields.some((f: string) => f === 'id' || f === '_id');
323
359
  if (!hasIdField) {
324
360
  (findOptions.projection as any)._id = 0;
325
361
  }
@@ -383,9 +419,12 @@ export class MongoDriver implements Driver {
383
419
 
384
420
  async count(objectName: string, filters: any, options?: any): Promise<number> {
385
421
  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;
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
+ }
389
428
  const filter = this.mapFilters(actualFilters);
390
429
  return await collection.countDocuments(filter);
391
430
  }
@@ -452,19 +491,9 @@ export class MongoDriver implements Driver {
452
491
  * @returns Query results with value and count
453
492
  */
454
493
  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);
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);
468
497
 
469
498
  return {
470
499
  value: results,
@@ -575,54 +604,9 @@ export class MongoDriver implements Driver {
575
604
  }
576
605
 
577
606
  /**
578
- * Convert FilterNode (QueryAST format) to legacy filter array format
607
+ * Convert FilterCondition (QueryAST format) to legacy filter array format
579
608
  * This allows reuse of existing filter logic while supporting new QueryAST
580
609
  *
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
610
  /**
627
611
  * Execute command (alternative signature for compatibility)
628
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
  };