Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 85 additions & 48 deletions packages/drivers/mongo/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,65 +64,102 @@ export class MongoDriver implements Driver {
private mapFilters(filters: any): Filter<any> {
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<any> {
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<any[]> {
Expand Down
100 changes: 100 additions & 0 deletions packages/drivers/mongo/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
});

});
62 changes: 62 additions & 0 deletions packages/drivers/mongo/test/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down