@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/CHANGELOG.md +14 -0
- package/dist/index.d.ts +6 -5
- package/dist/index.js +89 -99
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +95 -111
- package/test/index.test.ts +64 -70
- package/tsconfig.tsbuildinfo +1 -1
package/src/index.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { Data,
|
|
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 =
|
|
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
|
|
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
|
-
|
|
262
|
-
|
|
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
|
-
//
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
if (
|
|
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
|
|
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
|
-
|
|
349
|
+
|
|
350
|
+
if (query.fields && query.fields.length > 0) {
|
|
315
351
|
findOptions.projection = {};
|
|
316
|
-
for (const field of
|
|
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 =
|
|
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
|
-
//
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
|
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
|
*
|
package/test/index.test.ts
CHANGED
|
@@ -62,23 +62,16 @@ describe('MongoDriver', () => {
|
|
|
62
62
|
|
|
63
63
|
it('should find objects with query', async () => {
|
|
64
64
|
const query = {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
241
|
-
[
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
275
|
-
[
|
|
276
|
-
|
|
277
|
-
[
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
317
|
-
[
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
field: 'status',
|
|
347
|
-
operator: '=',
|
|
348
|
-
value: 'active'
|
|
352
|
+
where: {
|
|
353
|
+
status: 'active'
|
|
349
354
|
},
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
393
|
+
orderBy: [
|
|
400
394
|
{ field: 'name', order: 'asc' as const }
|
|
401
395
|
]
|
|
402
396
|
};
|