diff --git a/packages/drivers/mongo/src/index.ts b/packages/drivers/mongo/src/index.ts index 63c2cde2..07b50ad1 100644 --- a/packages/drivers/mongo/src/index.ts +++ b/packages/drivers/mongo/src/index.ts @@ -64,65 +64,102 @@ export class MongoDriver implements Driver { private mapFilters(filters: any): Filter { if (!filters || filters.length === 0) return {}; - // Simple case: Array of arrays [['a','=','1'], ['b','=','2']] (Implicit AND) - // or Mixed: [['a','=','1'], 'and', ['b','=','2']] - + const result = this.buildFilterConditions(filters); + return result; + } + + /** + * Build MongoDB filter conditions from ObjectQL filter array. + * Supports nested filter groups and logical operators (AND/OR). + */ + private buildFilterConditions(filters: any[]): Filter { const conditions: any[] = []; - let currentLogic = '$and'; // Default implicit logic + let nextJoin = '$and'; // Default logic operator for next condition for (const item of filters) { - if (Array.isArray(item)) { - const [field, op, value] = item; - let mongoCondition: any = {}; - - // Map both 'id' and '_id' to '_id' for MongoDB compatibility - // This ensures backward compatibility for queries using '_id' - const dbField = (field === 'id' || field === '_id') ? '_id' : field; - - if (dbField === '_id') { - mongoCondition[dbField] = this.normalizeId(value); - } else { - switch (op) { - case '=': mongoCondition[dbField] = { $eq: value }; break; - case '!=': mongoCondition[dbField] = { $ne: value }; break; - case '>': mongoCondition[dbField] = { $gt: value }; break; - case '>=': mongoCondition[dbField] = { $gte: value }; break; - case '<': mongoCondition[dbField] = { $lt: value }; break; - case '<=': mongoCondition[dbField] = { $lte: value }; break; - case 'in': mongoCondition[dbField] = { $in: value }; break; - case 'nin': mongoCondition[dbField] = { $nin: value }; break; - case 'contains': - // Basic regex escape should be added for safety - mongoCondition[dbField] = { $regex: value, $options: 'i' }; - break; - default: mongoCondition[dbField] = { $eq: value }; - } - } - conditions.push(mongoCondition); + if (typeof item === 'string') { + // Update the logic operator for the next condition + if (item.toLowerCase() === 'or') { + nextJoin = '$or'; + } else if (item.toLowerCase() === 'and') { + nextJoin = '$and'; + } + continue; + } + + if (Array.isArray(item)) { + // Heuristic to detect if it is a criterion [field, op, value] or a nested group + const [fieldRaw, op, value] = item; + const isCriterion = typeof fieldRaw === 'string' && typeof op === 'string'; - } else if (typeof item === 'string') { - if (item.toLowerCase() === 'or') { - // If we encounter an OR, we might need to restructure. - // For simplicity in this v1, let's assume simple ANDs usually, - // OR handling requires more complex tree parsing if mixed. - // But if it's strictly [A, 'or', B], we can do strict mapping. - // This is a simplified parser: - currentLogic = '$or'; - } else if (item.toLowerCase() === 'and') { - currentLogic = '$and'; - } - } + let condition: any; + + if (isCriterion) { + // This is a single criterion [field, op, value] + condition = this.buildSingleCondition(fieldRaw, op, value); + } else { + // This is a nested group - recursively process it + condition = this.buildFilterConditions(item); + } + + // Apply the join logic + if (conditions.length > 0 && nextJoin === '$or') { + // Collect all OR conditions together + const lastItem = conditions[conditions.length - 1]; + if (lastItem && lastItem.$or) { + // Extend existing $or array + lastItem.$or.push(condition); + } else { + // Create new $or with previous and current condition + const previous = conditions.pop(); + conditions.push({ $or: [previous, condition] }); + } + } else { + // Default AND - just add to conditions array + conditions.push(condition); + } + + // Reset to default AND logic after processing each item + nextJoin = '$and'; + } } if (conditions.length === 0) return {}; if (conditions.length === 1) return conditions[0]; - // If 'or' was detected, wrap all in $or (very naive implementation) - if (currentLogic === '$or') { - return { $or: conditions }; + // Multiple conditions - wrap in AND + return { $and: conditions }; + } + + /** + * Build a single MongoDB condition from field, operator, and value. + */ + private buildSingleCondition(fieldRaw: string, op: string, value: any): any { + const mongoCondition: any = {}; + + // Map both 'id' and '_id' to '_id' for MongoDB compatibility + const dbField = (fieldRaw === 'id' || fieldRaw === '_id') ? '_id' : fieldRaw; + + if (dbField === '_id') { + mongoCondition[dbField] = this.normalizeId(value); + } else { + switch (op) { + case '=': mongoCondition[dbField] = { $eq: value }; break; + case '!=': mongoCondition[dbField] = { $ne: value }; break; + case '>': mongoCondition[dbField] = { $gt: value }; break; + case '>=': mongoCondition[dbField] = { $gte: value }; break; + case '<': mongoCondition[dbField] = { $lt: value }; break; + case '<=': mongoCondition[dbField] = { $lte: value }; break; + case 'in': mongoCondition[dbField] = { $in: value }; break; + case 'nin': mongoCondition[dbField] = { $nin: value }; break; + case 'contains': + mongoCondition[dbField] = { $regex: value, $options: 'i' }; + break; + default: mongoCondition[dbField] = { $eq: value }; + } } - return { $and: conditions }; + return mongoCondition; } async find(objectName: string, query: any, options?: any): Promise { diff --git a/packages/drivers/mongo/test/index.test.ts b/packages/drivers/mongo/test/index.test.ts index 1cdb38c8..0f837790 100644 --- a/packages/drivers/mongo/test/index.test.ts +++ b/packages/drivers/mongo/test/index.test.ts @@ -220,4 +220,104 @@ describe('MongoDriver', () => { expect(results[0]).toEqual({ id: '456', name: 'Dave' }); }); + it('should handle nested filter groups', async () => { + const query = { + filters: [ + [ + ['status', '=', 'completed'], + 'and', + ['amount', '>', 100] + ], + 'or', + [ + ['customer', '=', 'Alice'], + 'and', + ['status', '=', 'pending'] + ] + ] + }; + await driver.find('orders', query); + + // Expected MongoDB query structure: + // { $or: [ + // { $and: [{ status: { $eq: 'completed' } }, { amount: { $gt: 100 } }] }, + // { $and: [{ customer: { $eq: 'Alice' } }, { status: { $eq: 'pending' } }] } + // ] } + expect(mockCollection.find).toHaveBeenCalledWith( + { + $or: [ + { $and: [{ status: { $eq: 'completed' } }, { amount: { $gt: 100 } }] }, + { $and: [{ customer: { $eq: 'Alice' } }, { status: { $eq: 'pending' } }] } + ] + }, + expect.any(Object) + ); + }); + + it('should handle deeply nested filter groups', async () => { + const query = { + filters: [ + [ + [ + ['age', '>', 22], + 'and', + ['status', '=', 'active'] + ], + 'or', + ['role', '=', 'admin'] + ], + 'and', + ['name', '!=', 'Bob'] + ] + }; + await driver.find('users', query); + + // Expected structure: + // { $and: [ + // { $or: [ + // { $and: [{ age: { $gt: 22 } }, { status: { $eq: 'active' } }] }, + // { role: { $eq: 'admin' } } + // ] }, + // { name: { $ne: 'Bob' } } + // ] } + expect(mockCollection.find).toHaveBeenCalledWith( + { + $and: [ + { + $or: [ + { $and: [{ age: { $gt: 22 } }, { status: { $eq: 'active' } }] }, + { role: { $eq: 'admin' } } + ] + }, + { name: { $ne: 'Bob' } } + ] + }, + expect.any(Object) + ); + }); + + it('should handle nested groups with implicit AND', async () => { + const query = { + filters: [ + [ + ['status', '=', 'active'], + ['role', '=', 'admin'] + ], + ['age', '>', 25] + ] + }; + await driver.find('users', query); + + // Nested array without explicit 'and' should still be treated as AND + expect(mockCollection.find).toHaveBeenCalledWith( + { + $and: [ + { $and: [{ status: { $eq: 'active' } }, { role: { $eq: 'admin' } }] }, + { age: { $gt: 25 } } + ] + }, + expect.any(Object) + ); + }); + }); diff --git a/packages/drivers/mongo/test/integration.test.ts b/packages/drivers/mongo/test/integration.test.ts index 492b72bf..a2ec20bc 100644 --- a/packages/drivers/mongo/test/integration.test.ts +++ b/packages/drivers/mongo/test/integration.test.ts @@ -539,6 +539,68 @@ describe('MongoDriver Integration Tests', () => { expect(results.length).toBe(2); }); + test('should handle nested filter groups', async () => { + // Create test data matching the SQL driver's advanced test + await driver.create('orders', { customer: 'Alice', product: 'Laptop', amount: 1200.00, quantity: 1, status: 'completed' }); + await driver.create('orders', { customer: 'Bob', product: 'Mouse', amount: 25.50, quantity: 2, status: 'completed' }); + await driver.create('orders', { customer: 'Alice', product: 'Keyboard', amount: 75.00, quantity: 1, status: 'pending' }); + await driver.create('orders', { customer: 'Charlie', product: 'Monitor', amount: 350.00, quantity: 1, status: 'completed' }); + + // Nested filter: (status = 'completed' AND amount > 100) OR (customer = 'Alice' AND status = 'pending') + const results = await driver.find('orders', { + filters: [ + [ + ['status', '=', 'completed'], + 'and', + ['amount', '>', 100] + ], + 'or', + [ + ['customer', '=', 'Alice'], + 'and', + ['status', '=', 'pending'] + ] + ] + }); + + // Should match: Alice's Laptop (completed, 1200), Charlie's Monitor (completed, 350), Alice's Keyboard (pending) + expect(results.length).toBe(3); + + const customers = results.map(r => r.customer).sort(); + expect(customers).toEqual(['Alice', 'Alice', 'Charlie']); + }); + + test('should handle deeply nested filters', async () => { + await driver.create('users', { name: 'Alice', age: 25, status: 'active', role: 'admin' }); + await driver.create('users', { name: 'Bob', age: 30, status: 'active', role: 'user' }); + await driver.create('users', { name: 'Charlie', age: 20, status: 'inactive', role: 'user' }); + await driver.create('users', { name: 'Dave', age: 35, status: 'active', role: 'admin' }); + + // Complex nested: ((age > 22 AND status = 'active') OR role = 'admin') AND name != 'Bob' + const results = await driver.find('users', { + filters: [ + [ + [ + ['age', '>', 22], + 'and', + ['status', '=', 'active'] + ], + 'or', + ['role', '=', 'admin'] + ], + 'and', + ['name', '!=', 'Bob'] + ] + }); + + // Should match: Alice (age>22 AND active), Dave (age>22 AND active AND admin) + // Should NOT match: Bob (excluded by name filter), Charlie (age<=22, inactive, not admin) + expect(results.length).toBe(2); + const names = results.map(r => r.name).sort(); + expect(names).toEqual(['Alice', 'Dave']); + }); + + test('should handle nin (not in) filter', async () => { await driver.create('users', { name: 'Alice', status: 'active' }); await driver.create('users', { name: 'Bob', status: 'inactive' });