Skip to content

Commit 16ea08c

Browse files
Copilothotlong
andcommitted
Implement recursive nested filter support for Mongo driver
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 771853f commit 16ea08c

3 files changed

Lines changed: 242 additions & 43 deletions

File tree

packages/drivers/mongo/src/index.ts

Lines changed: 80 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -64,65 +64,102 @@ export class MongoDriver implements Driver {
6464
private mapFilters(filters: any): Filter<any> {
6565
if (!filters || filters.length === 0) return {};
6666

67-
// Simple case: Array of arrays [['a','=','1'], ['b','=','2']] (Implicit AND)
68-
// or Mixed: [['a','=','1'], 'and', ['b','=','2']]
69-
67+
const result = this.buildFilterConditions(filters);
68+
return result;
69+
}
70+
71+
/**
72+
* Build MongoDB filter conditions from ObjectQL filter array.
73+
* Supports nested filter groups and logical operators (AND/OR).
74+
*/
75+
private buildFilterConditions(filters: any[]): Filter<any> {
7076
const conditions: any[] = [];
71-
let currentLogic = '$and'; // Default implicit logic
77+
let nextJoin = '$and'; // Default logic operator for next condition
7278

7379
for (const item of filters) {
80+
if (typeof item === 'string') {
81+
// Update the logic operator for the next condition
82+
if (item.toLowerCase() === 'or') {
83+
nextJoin = '$or';
84+
} else if (item.toLowerCase() === 'and') {
85+
nextJoin = '$and';
86+
}
87+
continue;
88+
}
89+
7490
if (Array.isArray(item)) {
75-
const [field, op, value] = item;
76-
let mongoCondition: any = {};
77-
78-
// Map both 'id' and '_id' to '_id' for MongoDB compatibility
79-
// This ensures backward compatibility for queries using '_id'
80-
const dbField = (field === 'id' || field === '_id') ? '_id' : field;
91+
// Heuristic to detect if it is a criterion [field, op, value] or a nested group
92+
const [fieldRaw, op, value] = item;
93+
const isCriterion = typeof fieldRaw === 'string' && typeof op === 'string';
94+
95+
let condition: any;
8196

82-
if (dbField === '_id') {
83-
mongoCondition[dbField] = this.normalizeId(value);
97+
if (isCriterion) {
98+
// This is a single criterion [field, op, value]
99+
condition = this.buildSingleCondition(fieldRaw, op, value);
84100
} else {
85-
switch (op) {
86-
case '=': mongoCondition[dbField] = { $eq: value }; break;
87-
case '!=': mongoCondition[dbField] = { $ne: value }; break;
88-
case '>': mongoCondition[dbField] = { $gt: value }; break;
89-
case '>=': mongoCondition[dbField] = { $gte: value }; break;
90-
case '<': mongoCondition[dbField] = { $lt: value }; break;
91-
case '<=': mongoCondition[dbField] = { $lte: value }; break;
92-
case 'in': mongoCondition[dbField] = { $in: value }; break;
93-
case 'nin': mongoCondition[dbField] = { $nin: value }; break;
94-
case 'contains':
95-
// Basic regex escape should be added for safety
96-
mongoCondition[dbField] = { $regex: value, $options: 'i' };
97-
break;
98-
default: mongoCondition[dbField] = { $eq: value };
99-
}
101+
// This is a nested group - recursively process it
102+
condition = this.buildFilterConditions(item);
100103
}
101-
conditions.push(mongoCondition);
102-
103-
} else if (typeof item === 'string') {
104-
if (item.toLowerCase() === 'or') {
105-
// If we encounter an OR, we might need to restructure.
106-
// For simplicity in this v1, let's assume simple ANDs usually,
107-
// OR handling requires more complex tree parsing if mixed.
108-
// But if it's strictly [A, 'or', B], we can do strict mapping.
109-
// This is a simplified parser:
110-
currentLogic = '$or';
111-
} else if (item.toLowerCase() === 'and') {
112-
currentLogic = '$and';
104+
105+
// Apply the join logic
106+
if (conditions.length > 0 && nextJoin === '$or') {
107+
// Collect all OR conditions together
108+
const lastItem = conditions[conditions.length - 1];
109+
if (lastItem && lastItem.$or) {
110+
// Extend existing $or array
111+
lastItem.$or.push(condition);
112+
} else {
113+
// Create new $or with previous and current condition
114+
const previous = conditions.pop();
115+
conditions.push({ $or: [previous, condition] });
116+
}
117+
} else {
118+
// Default AND - just add to conditions array
119+
conditions.push(condition);
113120
}
121+
122+
// Reset to default AND logic after processing each item
123+
nextJoin = '$and';
114124
}
115125
}
116126

117127
if (conditions.length === 0) return {};
118128
if (conditions.length === 1) return conditions[0];
119129

120-
// If 'or' was detected, wrap all in $or (very naive implementation)
121-
if (currentLogic === '$or') {
122-
return { $or: conditions };
130+
// Multiple conditions - wrap in AND
131+
return { $and: conditions };
132+
}
133+
134+
/**
135+
* Build a single MongoDB condition from field, operator, and value.
136+
*/
137+
private buildSingleCondition(fieldRaw: string, op: string, value: any): any {
138+
const mongoCondition: any = {};
139+
140+
// Map both 'id' and '_id' to '_id' for MongoDB compatibility
141+
const dbField = (fieldRaw === 'id' || fieldRaw === '_id') ? '_id' : fieldRaw;
142+
143+
if (dbField === '_id') {
144+
mongoCondition[dbField] = this.normalizeId(value);
145+
} else {
146+
switch (op) {
147+
case '=': mongoCondition[dbField] = { $eq: value }; break;
148+
case '!=': mongoCondition[dbField] = { $ne: value }; break;
149+
case '>': mongoCondition[dbField] = { $gt: value }; break;
150+
case '>=': mongoCondition[dbField] = { $gte: value }; break;
151+
case '<': mongoCondition[dbField] = { $lt: value }; break;
152+
case '<=': mongoCondition[dbField] = { $lte: value }; break;
153+
case 'in': mongoCondition[dbField] = { $in: value }; break;
154+
case 'nin': mongoCondition[dbField] = { $nin: value }; break;
155+
case 'contains':
156+
mongoCondition[dbField] = { $regex: value, $options: 'i' };
157+
break;
158+
default: mongoCondition[dbField] = { $eq: value };
159+
}
123160
}
124161

125-
return { $and: conditions };
162+
return mongoCondition;
126163
}
127164

128165
async find(objectName: string, query: any, options?: any): Promise<any[]> {

packages/drivers/mongo/test/index.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,4 +220,104 @@ describe('MongoDriver', () => {
220220
expect(results[0]).toEqual({ id: '456', name: 'Dave' });
221221
});
222222

223+
it('should handle nested filter groups', async () => {
224+
const query = {
225+
filters: [
226+
[
227+
['status', '=', 'completed'],
228+
'and',
229+
['amount', '>', 100]
230+
],
231+
'or',
232+
[
233+
['customer', '=', 'Alice'],
234+
'and',
235+
['status', '=', 'pending']
236+
]
237+
]
238+
};
239+
await driver.find('orders', query);
240+
241+
// Expected MongoDB query structure:
242+
// { $or: [
243+
// { $and: [{ status: { $eq: 'completed' } }, { amount: { $gt: 100 } }] },
244+
// { $and: [{ customer: { $eq: 'Alice' } }, { status: { $eq: 'pending' } }] }
245+
// ] }
246+
expect(mockCollection.find).toHaveBeenCalledWith(
247+
{
248+
$or: [
249+
{ $and: [{ status: { $eq: 'completed' } }, { amount: { $gt: 100 } }] },
250+
{ $and: [{ customer: { $eq: 'Alice' } }, { status: { $eq: 'pending' } }] }
251+
]
252+
},
253+
expect.any(Object)
254+
);
255+
});
256+
257+
it('should handle deeply nested filter groups', async () => {
258+
const query = {
259+
filters: [
260+
[
261+
[
262+
['age', '>', 22],
263+
'and',
264+
['status', '=', 'active']
265+
],
266+
'or',
267+
['role', '=', 'admin']
268+
],
269+
'and',
270+
['name', '!=', 'Bob']
271+
]
272+
};
273+
await driver.find('users', query);
274+
275+
// Expected structure:
276+
// { $and: [
277+
// { $or: [
278+
// { $and: [{ age: { $gt: 22 } }, { status: { $eq: 'active' } }] },
279+
// { role: { $eq: 'admin' } }
280+
// ] },
281+
// { name: { $ne: 'Bob' } }
282+
// ] }
283+
expect(mockCollection.find).toHaveBeenCalledWith(
284+
{
285+
$and: [
286+
{
287+
$or: [
288+
{ $and: [{ age: { $gt: 22 } }, { status: { $eq: 'active' } }] },
289+
{ role: { $eq: 'admin' } }
290+
]
291+
},
292+
{ name: { $ne: 'Bob' } }
293+
]
294+
},
295+
expect.any(Object)
296+
);
297+
});
298+
299+
it('should handle nested groups with implicit AND', async () => {
300+
const query = {
301+
filters: [
302+
[
303+
['status', '=', 'active'],
304+
['role', '=', 'admin']
305+
],
306+
['age', '>', 25]
307+
]
308+
};
309+
await driver.find('users', query);
310+
311+
// Nested array without explicit 'and' should still be treated as AND
312+
expect(mockCollection.find).toHaveBeenCalledWith(
313+
{
314+
$and: [
315+
{ $and: [{ status: { $eq: 'active' } }, { role: { $eq: 'admin' } }] },
316+
{ age: { $gt: 25 } }
317+
]
318+
},
319+
expect.any(Object)
320+
);
321+
});
322+
223323
});

packages/drivers/mongo/test/integration.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,68 @@ describe('MongoDriver Integration Tests', () => {
539539
expect(results.length).toBe(2);
540540
});
541541

542+
test('should handle nested filter groups', async () => {
543+
// Create test data matching the SQL driver's advanced test
544+
await driver.create('orders', { customer: 'Alice', product: 'Laptop', amount: 1200.00, quantity: 1, status: 'completed' });
545+
await driver.create('orders', { customer: 'Bob', product: 'Mouse', amount: 25.50, quantity: 2, status: 'completed' });
546+
await driver.create('orders', { customer: 'Alice', product: 'Keyboard', amount: 75.00, quantity: 1, status: 'pending' });
547+
await driver.create('orders', { customer: 'Charlie', product: 'Monitor', amount: 350.00, quantity: 1, status: 'completed' });
548+
549+
// Nested filter: (status = 'completed' AND amount > 100) OR (customer = 'Alice' AND status = 'pending')
550+
const results = await driver.find('orders', {
551+
filters: [
552+
[
553+
['status', '=', 'completed'],
554+
'and',
555+
['amount', '>', 100]
556+
],
557+
'or',
558+
[
559+
['customer', '=', 'Alice'],
560+
'and',
561+
['status', '=', 'pending']
562+
]
563+
]
564+
});
565+
566+
// Should match: Alice's Laptop (completed, 1200), Charlie's Monitor (completed, 350), Alice's Keyboard (pending)
567+
expect(results.length).toBe(3);
568+
569+
const customers = results.map(r => r.customer).sort();
570+
expect(customers).toEqual(['Alice', 'Alice', 'Charlie']);
571+
});
572+
573+
test('should handle deeply nested filters', async () => {
574+
await driver.create('users', { name: 'Alice', age: 25, status: 'active', role: 'admin' });
575+
await driver.create('users', { name: 'Bob', age: 30, status: 'active', role: 'user' });
576+
await driver.create('users', { name: 'Charlie', age: 20, status: 'inactive', role: 'user' });
577+
await driver.create('users', { name: 'Dave', age: 35, status: 'active', role: 'admin' });
578+
579+
// Complex nested: ((age > 22 AND status = 'active') OR role = 'admin') AND name != 'Bob'
580+
const results = await driver.find('users', {
581+
filters: [
582+
[
583+
[
584+
['age', '>', 22],
585+
'and',
586+
['status', '=', 'active']
587+
],
588+
'or',
589+
['role', '=', 'admin']
590+
],
591+
'and',
592+
['name', '!=', 'Bob']
593+
]
594+
});
595+
596+
// Should match: Alice (age>22 AND active), Dave (age>22 AND active AND admin)
597+
// Should NOT match: Bob (excluded by name filter), Charlie (age<=22, inactive, not admin)
598+
expect(results.length).toBe(2);
599+
const names = results.map(r => r.name).sort();
600+
expect(names).toEqual(['Alice', 'Dave']);
601+
});
602+
603+
542604
test('should handle nin (not in) filter', async () => {
543605
await driver.create('users', { name: 'Alice', status: 'active' });
544606
await driver.create('users', { name: 'Bob', status: 'inactive' });

0 commit comments

Comments
 (0)