feat: Add comprehensive query filters guide and enhance type safety for MongoDB queries
- Introduced a detailed guide on query filters in the README, covering basic filtering, comparison operators, array operators, logical operators, element operators, and advanced filtering patterns. - Implemented a type-safe filtering system in `classes.doc.ts` with `MongoFilterCondition` and `MongoFilter` types to support MongoDB operators while maintaining nested type safety. - Enhanced error handling for invalid operators and conditions in the filtering logic. - Added extensive tests for various filtering scenarios, including basic, comparison, array, logical, and complex filters, ensuring robust functionality and performance. - Implemented security measures to prevent the use of dangerous operators like `$where` and validate operator usage.
This commit is contained in:
		| @@ -7,7 +7,7 @@ | |||||||
|   "typings": "dist_ts/index.d.ts", |   "typings": "dist_ts/index.d.ts", | ||||||
|   "type": "module", |   "type": "module", | ||||||
|   "scripts": { |   "scripts": { | ||||||
|     "test": "tstest test/ --verbose", |     "test": "tstest test/ --verbose.  --logfile --timeout 120", | ||||||
|     "testSearch": "tsx test/test.search.ts", |     "testSearch": "tsx test/test.search.ts", | ||||||
|     "build": "tsbuild --web --allowimplicitany", |     "build": "tsbuild --web --allowimplicitany", | ||||||
|     "buildDocs": "tsdoc" |     "buildDocs": "tsdoc" | ||||||
| @@ -37,10 +37,10 @@ | |||||||
|     "mongodb": "^6.18.0" |     "mongodb": "^6.18.0" | ||||||
|   }, |   }, | ||||||
|   "devDependencies": { |   "devDependencies": { | ||||||
|     "@git.zone/tsbuild": "^2.6.4", |     "@git.zone/tsbuild": "^2.6.7", | ||||||
|     "@git.zone/tsrun": "^1.2.44", |     "@git.zone/tsrun": "^1.2.44", | ||||||
|     "@git.zone/tstest": "^2.3.2", |     "@git.zone/tstest": "^2.3.5", | ||||||
|     "@push.rocks/qenv": "^6.0.5", |     "@push.rocks/qenv": "^6.1.3", | ||||||
|     "@push.rocks/tapbundle": "^6.0.3", |     "@push.rocks/tapbundle": "^6.0.3", | ||||||
|     "@types/node": "^22.15.2" |     "@types/node": "^22.15.2" | ||||||
|   }, |   }, | ||||||
|   | |||||||
							
								
								
									
										1766
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1766
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										332
									
								
								readme.md
									
									
									
									
									
								
							
							
						
						
									
										332
									
								
								readme.md
									
									
									
									
									
								
							| @@ -132,6 +132,338 @@ await foundUser.save(); | |||||||
| await foundUser.delete(); | await foundUser.delete(); | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
|  | ## 🔍 Query Filters Guide | ||||||
|  |  | ||||||
|  | SmartData provides a comprehensive and type-safe filtering system that supports all MongoDB query operators while preventing dangerous operations like `$where` for security. | ||||||
|  |  | ||||||
|  | ### Basic Filtering | ||||||
|  |  | ||||||
|  | ```typescript | ||||||
|  | // Simple equality - find users with exact name | ||||||
|  | const johns = await User.getInstances({ name: 'John Doe' }); | ||||||
|  |  | ||||||
|  | // Multiple conditions (implicit AND) | ||||||
|  | const activeAdults = await User.getInstances({  | ||||||
|  |   status: 'active', | ||||||
|  |   age: { $gte: 18 } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Nested object fields - use dot notation | ||||||
|  | const recentUsers = await User.getInstances({ | ||||||
|  |   'metadata.lastLogin': { $gte: new Date('2024-01-01') }, | ||||||
|  |   'metadata.loginCount': { $gt: 5 } | ||||||
|  | }); | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### Comparison Operators | ||||||
|  |  | ||||||
|  | All standard MongoDB comparison operators are supported: | ||||||
|  |  | ||||||
|  | ```typescript | ||||||
|  | // Greater than / Greater than or equal | ||||||
|  | const adults = await User.getInstances({ age: { $gte: 18 } }); | ||||||
|  | const seniors = await User.getInstances({ age: { $gt: 65 } }); | ||||||
|  |  | ||||||
|  | // Less than / Less than or equal | ||||||
|  | const youth = await User.getInstances({ age: { $lt: 25 } }); | ||||||
|  | const under30 = await User.getInstances({ age: { $lte: 30 } }); | ||||||
|  |  | ||||||
|  | // Not equal | ||||||
|  | const notPending = await User.getInstances({ status: { $ne: 'pending' } }); | ||||||
|  |  | ||||||
|  | // Combine multiple comparisons | ||||||
|  | const middleAged = await User.getInstances({ | ||||||
|  |   age: { $gte: 30, $lt: 50 } | ||||||
|  | }); | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### Array Operators | ||||||
|  |  | ||||||
|  | SmartData fully supports MongoDB's array query operators: | ||||||
|  |  | ||||||
|  | ```typescript | ||||||
|  | // $in - Match any value in array | ||||||
|  | const adminsAndMods = await User.getInstances({  | ||||||
|  |   role: { $in: ['admin', 'moderator'] }  | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // $nin - Match none of the values | ||||||
|  | const regularUsers = await User.getInstances({  | ||||||
|  |   role: { $nin: ['admin', 'moderator'] }  | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // $all - Array contains all specified elements | ||||||
|  | const fullStackDevs = await User.getInstances({  | ||||||
|  |   skills: { $all: ['javascript', 'nodejs', 'mongodb'] }  | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // $size - Array has specific length | ||||||
|  | const usersWithThreeSkills = await User.getInstances({  | ||||||
|  |   skills: { $size: 3 }  | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // $elemMatch - Match array elements with multiple conditions | ||||||
|  | const ordersWithExpensiveItems = await Order.getInstances({ | ||||||
|  |   items: {  | ||||||
|  |     $elemMatch: {  | ||||||
|  |       product: 'laptop', | ||||||
|  |       quantity: { $gte: 2 }, | ||||||
|  |       price: { $gt: 1000 } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### Working with Arrays | ||||||
|  |  | ||||||
|  | ```typescript | ||||||
|  | // Find documents where array field contains a value | ||||||
|  | const javascriptUsers = await User.getInstances({ | ||||||
|  |   tags: { $in: ['javascript'] }  // Users with 'javascript' in tags array | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Find documents where array field contains ALL values | ||||||
|  | const expertUsers = await User.getInstances({ | ||||||
|  |   certifications: { $all: ['mongodb', 'nodejs', 'typescript'] } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Query nested arrays of objects | ||||||
|  | const highValueOrders = await Order.getInstances({ | ||||||
|  |   'items.price': { $gte: 100 }  // Orders with any item priced >= 100 | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Complex array element matching | ||||||
|  | const bulkElectronicsOrders = await Order.getInstances({ | ||||||
|  |   items: { | ||||||
|  |     $elemMatch: { | ||||||
|  |       category: 'electronics', | ||||||
|  |       quantity: { $gte: 5 }, | ||||||
|  |       discount: { $exists: true } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### Logical Operators | ||||||
|  |  | ||||||
|  | Combine multiple conditions with logical operators: | ||||||
|  |  | ||||||
|  | ```typescript | ||||||
|  | // $or - Match any condition | ||||||
|  | const urgentOrHighValue = await Order.getInstances({ | ||||||
|  |   $or: [ | ||||||
|  |     { priority: 'urgent' }, | ||||||
|  |     { totalAmount: { $gte: 1000 } } | ||||||
|  |   ] | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // $and - Match all conditions (usually implicit, but useful for complex queries) | ||||||
|  | const premiumActiveUsers = await User.getInstances({ | ||||||
|  |   $and: [ | ||||||
|  |     { subscription: 'premium' }, | ||||||
|  |     { status: 'active' }, | ||||||
|  |     { lastLogin: { $gte: new Date(Date.now() - 30*24*60*60*1000) } } | ||||||
|  |   ] | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // $nor - Match none of the conditions | ||||||
|  | const availableProducts = await Product.getInstances({ | ||||||
|  |   $nor: [ | ||||||
|  |     { status: 'discontinued' }, | ||||||
|  |     { inventory: { $lte: 0 } } | ||||||
|  |   ] | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // $not - Negate a condition | ||||||
|  | const nonEmptyArrays = await User.getInstances({ | ||||||
|  |   tags: { $not: { $size: 0 } } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Nested logical operations | ||||||
|  | const complexQuery = await User.getInstances({ | ||||||
|  |   $or: [ | ||||||
|  |     { | ||||||
|  |       $and: [ | ||||||
|  |         { role: 'admin' }, | ||||||
|  |         { department: 'IT' } | ||||||
|  |       ] | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       $and: [ | ||||||
|  |         { role: 'manager' }, | ||||||
|  |         { yearsExperience: { $gte: 5 } } | ||||||
|  |       ] | ||||||
|  |     } | ||||||
|  |   ] | ||||||
|  | }); | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### Element Operators | ||||||
|  |  | ||||||
|  | Check for field existence and types: | ||||||
|  |  | ||||||
|  | ```typescript | ||||||
|  | // $exists - Check if field exists | ||||||
|  | const usersWithAvatar = await User.getInstances({ | ||||||
|  |   'profile.avatar': { $exists: true } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const usersWithoutPhone = await User.getInstances({ | ||||||
|  |   phoneNumber: { $exists: false } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // $type - Check field type (MongoDB BSON types) | ||||||
|  | const usersWithStringId = await User.getInstances({ | ||||||
|  |   id: { $type: 'string' }  // or use type number: 2 for string | ||||||
|  | }); | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### Regular Expressions | ||||||
|  |  | ||||||
|  | Use regex for pattern matching: | ||||||
|  |  | ||||||
|  | ```typescript | ||||||
|  | // Case-insensitive search | ||||||
|  | const johnUsers = await User.getInstances({ | ||||||
|  |   name: { $regex: '^john', $options: 'i' } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Email domain matching | ||||||
|  | const gmailUsers = await User.getInstances({ | ||||||
|  |   email: { $regex: '@gmail\.com$' } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Complex pattern matching | ||||||
|  | const validPhones = await User.getInstances({ | ||||||
|  |   phone: { $regex: '^\+1-\d{3}-\d{3}-\d{4}$' } | ||||||
|  | }); | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### Advanced Filtering Patterns | ||||||
|  |  | ||||||
|  | ```typescript | ||||||
|  | // Combine multiple operator types | ||||||
|  | const targetUsers = await User.getInstances({ | ||||||
|  |   $and: [ | ||||||
|  |     { age: { $gte: 25, $lte: 45 } }, | ||||||
|  |     { status: { $in: ['active', 'premium'] } }, | ||||||
|  |     {  | ||||||
|  |       $or: [ | ||||||
|  |         { 'subscription.type': 'annual' }, | ||||||
|  |         { 'subscription.credits': { $gte: 100 } } | ||||||
|  |       ] | ||||||
|  |     }, | ||||||
|  |     { tags: { $all: ['verified', 'trusted'] } } | ||||||
|  |   ] | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Filter with post-processing validation | ||||||
|  | const results = await Product.search('laptop', { | ||||||
|  |   filter: {  | ||||||
|  |     price: { $lte: 2000 }, | ||||||
|  |     brand: { $in: ['Apple', 'Dell', 'Lenovo'] } | ||||||
|  |   }, | ||||||
|  |   validate: async (product) => { | ||||||
|  |     // Additional custom validation | ||||||
|  |     return product.warrantyYears >= 2 && product.inStock; | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Transaction-safe filtering | ||||||
|  | const session = db.startSession(); | ||||||
|  | await session.withTransaction(async () => { | ||||||
|  |   const users = await User.getInstances( | ||||||
|  |     { status: 'pending' }, | ||||||
|  |     { session }  // Pass session for transaction | ||||||
|  |   ); | ||||||
|  |   // Process users within transaction | ||||||
|  | }); | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### Performance Tips | ||||||
|  |  | ||||||
|  | 1. **Use Indexes**: Create indexes on frequently filtered fields: | ||||||
|  |    ```typescript | ||||||
|  |    @index() public status: string;  // Regular index | ||||||
|  |    @unI() public email: string;     // Unique index | ||||||
|  |    ``` | ||||||
|  |  | ||||||
|  | 2. **Limit Results**: Use cursors for large datasets: | ||||||
|  |    ```typescript | ||||||
|  |    const cursor = await User.getCursor({ status: 'active' }); | ||||||
|  |    await cursor.forEach(async (user) => { | ||||||
|  |      // Process one at a time | ||||||
|  |    }); | ||||||
|  |    ``` | ||||||
|  |  | ||||||
|  | 3. **Projection**: Retrieve only needed fields (using MongoDB native): | ||||||
|  |    ```typescript | ||||||
|  |    const collection = User.getCollection(); | ||||||
|  |    const results = await collection.mongoDbCollection | ||||||
|  |      .find({ status: 'active' }) | ||||||
|  |      .project({ name: 1, email: 1 }) | ||||||
|  |      .toArray(); | ||||||
|  |    ``` | ||||||
|  |  | ||||||
|  | ### Security Considerations | ||||||
|  |  | ||||||
|  | SmartData automatically prevents dangerous operations: | ||||||
|  |  | ||||||
|  | ```typescript | ||||||
|  | // ❌ These will throw errors: | ||||||
|  | await User.getInstances({  | ||||||
|  |   $where: 'this.age > 25'  // Blocked: JavaScript execution | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | await User.getInstances({ | ||||||
|  |   'user.name': { 'bad.key': 'value' }  // Blocked: dots in keys | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // ✅ Safe alternatives: | ||||||
|  | await User.getInstances({  | ||||||
|  |   age: { $gt: 25 }  // Use operators instead | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | await User.getInstances({ | ||||||
|  |   'user.name': 'value'  // Use dot notation for nested fields | ||||||
|  | }); | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### Common Pitfalls and Solutions | ||||||
|  |  | ||||||
|  | ```typescript | ||||||
|  | // ❌ Wrong: Array operators need arrays | ||||||
|  | await User.getInstances({  | ||||||
|  |   role: { $in: 'admin' }  // Error: $in requires array | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // ✅ Correct: | ||||||
|  | await User.getInstances({  | ||||||
|  |   role: { $in: ['admin'] } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // ❌ Wrong: Direct array comparison (exact match) | ||||||
|  | await User.getInstances({  | ||||||
|  |   tags: 'javascript'  // Only matches if tags = ['javascript'] exactly | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // ✅ Correct: Check if array contains element | ||||||
|  | await User.getInstances({  | ||||||
|  |   tags: { $in: ['javascript'] }  // Matches if 'javascript' is in tags | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // ❌ Wrong: Checking multiple array elements incorrectly | ||||||
|  | await User.getInstances({  | ||||||
|  |   skills: 'javascript', | ||||||
|  |   skills: 'nodejs'  // Object can't have duplicate keys! | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // ✅ Correct: Use $all for multiple elements | ||||||
|  | await User.getInstances({  | ||||||
|  |   skills: { $all: ['javascript', 'nodejs'] } | ||||||
|  | }); | ||||||
|  | ``` | ||||||
|  |  | ||||||
| ## 🔥 Advanced Features | ## 🔥 Advanced Features | ||||||
|  |  | ||||||
| ### 🔎 Powerful Search Engine | ### 🔎 Powerful Search Engine | ||||||
|   | |||||||
							
								
								
									
										604
									
								
								test/test.filters.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										604
									
								
								test/test.filters.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,604 @@ | |||||||
|  | import { tap, expect } from '@git.zone/tstest/tapbundle'; | ||||||
|  | import * as smartmongo from '@push.rocks/smartmongo'; | ||||||
|  | import * as smartunique from '@push.rocks/smartunique'; | ||||||
|  | import * as smartdata from '../ts/index.js'; | ||||||
|  |  | ||||||
|  | const { SmartdataDb, Collection, svDb, unI, index } = smartdata; | ||||||
|  |  | ||||||
|  | let smartmongoInstance: smartmongo.SmartMongo; | ||||||
|  | let testDb: smartdata.SmartdataDb; | ||||||
|  |  | ||||||
|  | // Define test document classes | ||||||
|  | @Collection(() => testDb) | ||||||
|  | class TestUser extends smartdata.SmartDataDbDoc<TestUser, TestUser> { | ||||||
|  |   @unI() | ||||||
|  |   public id: string = smartunique.shortId(); | ||||||
|  |  | ||||||
|  |   @svDb() | ||||||
|  |   public name: string; | ||||||
|  |  | ||||||
|  |   @svDb() | ||||||
|  |   public age: number; | ||||||
|  |  | ||||||
|  |   @svDb() | ||||||
|  |   public email: string; | ||||||
|  |  | ||||||
|  |   @svDb() | ||||||
|  |   public roles: string[]; | ||||||
|  |  | ||||||
|  |   @svDb() | ||||||
|  |   public tags: string[]; | ||||||
|  |  | ||||||
|  |   @svDb() | ||||||
|  |   public status: 'active' | 'inactive' | 'pending'; | ||||||
|  |  | ||||||
|  |   @svDb() | ||||||
|  |   public metadata: { | ||||||
|  |     lastLogin?: Date; | ||||||
|  |     loginCount?: number; | ||||||
|  |     preferences?: Record<string, any>; | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   @svDb() | ||||||
|  |   public scores: number[]; | ||||||
|  |  | ||||||
|  |   constructor(data: Partial<TestUser> = {}) { | ||||||
|  |     super(); | ||||||
|  |     Object.assign(this, data); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @Collection(() => testDb) | ||||||
|  | class TestOrder extends smartdata.SmartDataDbDoc<TestOrder, TestOrder> { | ||||||
|  |   @unI() | ||||||
|  |   public id: string = smartunique.shortId(); | ||||||
|  |  | ||||||
|  |   @svDb() | ||||||
|  |   public userId: string; | ||||||
|  |  | ||||||
|  |   @svDb() | ||||||
|  |   public items: Array<{ | ||||||
|  |     product: string; | ||||||
|  |     quantity: number; | ||||||
|  |     price: number; | ||||||
|  |   }>; | ||||||
|  |  | ||||||
|  |   @svDb() | ||||||
|  |   public totalAmount: number; | ||||||
|  |  | ||||||
|  |   @svDb() | ||||||
|  |   public status: string; | ||||||
|  |  | ||||||
|  |   @svDb() | ||||||
|  |   public tags: string[]; | ||||||
|  |  | ||||||
|  |   constructor(data: Partial<TestOrder> = {}) { | ||||||
|  |     super(); | ||||||
|  |     Object.assign(this, data); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Setup and teardown | ||||||
|  | tap.test('should create a test database instance', async () => { | ||||||
|  |   smartmongoInstance = await smartmongo.SmartMongo.createAndStart(); | ||||||
|  |   testDb = new smartdata.SmartdataDb(await smartmongoInstance.getMongoDescriptor()); | ||||||
|  |   await testDb.init(); | ||||||
|  |   expect(testDb).toBeInstanceOf(SmartdataDb); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | tap.test('should create test data', async () => { | ||||||
|  |   // Create test users | ||||||
|  |   const users = [ | ||||||
|  |     new TestUser({ | ||||||
|  |       name: 'John Doe', | ||||||
|  |       age: 30, | ||||||
|  |       email: 'john@example.com', | ||||||
|  |       roles: ['admin', 'user'], | ||||||
|  |       tags: ['javascript', 'nodejs', 'mongodb'], | ||||||
|  |       status: 'active', | ||||||
|  |       metadata: { loginCount: 5, lastLogin: new Date() }, | ||||||
|  |       scores: [85, 90, 78] | ||||||
|  |     }), | ||||||
|  |     new TestUser({ | ||||||
|  |       name: 'Jane Smith', | ||||||
|  |       age: 25, | ||||||
|  |       email: 'jane@example.com', | ||||||
|  |       roles: ['user'], | ||||||
|  |       tags: ['python', 'mongodb'], | ||||||
|  |       status: 'active', | ||||||
|  |       metadata: { loginCount: 3 }, | ||||||
|  |       scores: [92, 88, 95] | ||||||
|  |     }), | ||||||
|  |     new TestUser({ | ||||||
|  |       name: 'Bob Johnson', | ||||||
|  |       age: 35, | ||||||
|  |       email: 'bob@example.com', | ||||||
|  |       roles: ['moderator', 'user'], | ||||||
|  |       tags: ['javascript', 'react', 'nodejs'], | ||||||
|  |       status: 'inactive', | ||||||
|  |       metadata: { loginCount: 0 }, | ||||||
|  |       scores: [70, 75, 80] | ||||||
|  |     }), | ||||||
|  |     new TestUser({ | ||||||
|  |       name: 'Alice Brown', | ||||||
|  |       age: 28, | ||||||
|  |       email: 'alice@example.com', | ||||||
|  |       roles: ['admin'], | ||||||
|  |       tags: ['typescript', 'angular', 'mongodb'], | ||||||
|  |       status: 'active', | ||||||
|  |       metadata: { loginCount: 10 }, | ||||||
|  |       scores: [95, 98, 100] | ||||||
|  |     }), | ||||||
|  |     new TestUser({ | ||||||
|  |       name: 'Charlie Wilson', | ||||||
|  |       age: 22, | ||||||
|  |       email: 'charlie@example.com', | ||||||
|  |       roles: ['user'], | ||||||
|  |       tags: ['golang', 'kubernetes'], | ||||||
|  |       status: 'pending', | ||||||
|  |       metadata: { loginCount: 1 }, | ||||||
|  |       scores: [60, 65] | ||||||
|  |     }) | ||||||
|  |   ]; | ||||||
|  |  | ||||||
|  |   for (const user of users) { | ||||||
|  |     await user.save(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // Create test orders | ||||||
|  |   const orders = [ | ||||||
|  |     new TestOrder({ | ||||||
|  |       userId: users[0].id, | ||||||
|  |       items: [ | ||||||
|  |         { product: 'laptop', quantity: 1, price: 1200 }, | ||||||
|  |         { product: 'mouse', quantity: 2, price: 25 } | ||||||
|  |       ], | ||||||
|  |       totalAmount: 1250, | ||||||
|  |       status: 'completed', | ||||||
|  |       tags: ['electronics', 'priority'] | ||||||
|  |     }), | ||||||
|  |     new TestOrder({ | ||||||
|  |       userId: users[1].id, | ||||||
|  |       items: [ | ||||||
|  |         { product: 'book', quantity: 3, price: 15 }, | ||||||
|  |         { product: 'pen', quantity: 5, price: 2 } | ||||||
|  |       ], | ||||||
|  |       totalAmount: 55, | ||||||
|  |       status: 'pending', | ||||||
|  |       tags: ['stationery'] | ||||||
|  |     }), | ||||||
|  |     new TestOrder({ | ||||||
|  |       userId: users[0].id, | ||||||
|  |       items: [ | ||||||
|  |         { product: 'laptop', quantity: 2, price: 1200 }, | ||||||
|  |         { product: 'keyboard', quantity: 2, price: 80 } | ||||||
|  |       ], | ||||||
|  |       totalAmount: 2560, | ||||||
|  |       status: 'processing', | ||||||
|  |       tags: ['electronics', 'bulk'] | ||||||
|  |     }) | ||||||
|  |   ]; | ||||||
|  |  | ||||||
|  |   for (const order of orders) { | ||||||
|  |     await order.save(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   const savedUsers = await TestUser.getInstances({}); | ||||||
|  |   const savedOrders = await TestOrder.getInstances({}); | ||||||
|  |   expect(savedUsers.length).toEqual(5); | ||||||
|  |   expect(savedOrders.length).toEqual(3); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // ============= BASIC FILTER TESTS ============= | ||||||
|  | tap.test('should filter by simple equality', async () => { | ||||||
|  |   const users = await TestUser.getInstances({ name: 'John Doe' }); | ||||||
|  |   expect(users.length).toEqual(1); | ||||||
|  |   expect(users[0].name).toEqual('John Doe'); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | tap.test('should filter by multiple fields (implicit AND)', async () => { | ||||||
|  |   const users = await TestUser.getInstances({  | ||||||
|  |     status: 'active', | ||||||
|  |     age: 30 | ||||||
|  |   }); | ||||||
|  |   expect(users.length).toEqual(1); | ||||||
|  |   expect(users[0].name).toEqual('John Doe'); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | tap.test('should filter by nested object fields', async () => { | ||||||
|  |   const users = await TestUser.getInstances({ | ||||||
|  |     'metadata.loginCount': 5 | ||||||
|  |   }); | ||||||
|  |   expect(users.length).toEqual(1); | ||||||
|  |   expect(users[0].name).toEqual('John Doe'); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // ============= COMPARISON OPERATOR TESTS ============= | ||||||
|  | tap.test('should filter using $gt operator', async () => { | ||||||
|  |   const users = await TestUser.getInstances({ | ||||||
|  |     age: { $gt: 30 } | ||||||
|  |   }); | ||||||
|  |   expect(users.length).toEqual(1); | ||||||
|  |   expect(users[0].name).toEqual('Bob Johnson'); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | tap.test('should filter using $gte operator', async () => { | ||||||
|  |   const users = await TestUser.getInstances({ | ||||||
|  |     age: { $gte: 30 } | ||||||
|  |   }); | ||||||
|  |   expect(users.length).toEqual(2); | ||||||
|  |   const names = users.map(u => u.name).sort(); | ||||||
|  |   expect(names).toEqual(['Bob Johnson', 'John Doe']); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | tap.test('should filter using $lt operator', async () => { | ||||||
|  |   const users = await TestUser.getInstances({ | ||||||
|  |     age: { $lt: 25 } | ||||||
|  |   }); | ||||||
|  |   expect(users.length).toEqual(1); | ||||||
|  |   expect(users[0].name).toEqual('Charlie Wilson'); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | tap.test('should filter using $lte operator', async () => { | ||||||
|  |   const users = await TestUser.getInstances({ | ||||||
|  |     age: { $lte: 25 } | ||||||
|  |   }); | ||||||
|  |   expect(users.length).toEqual(2); | ||||||
|  |   const names = users.map(u => u.name).sort(); | ||||||
|  |   expect(names).toEqual(['Charlie Wilson', 'Jane Smith']); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | tap.test('should filter using $ne operator', async () => { | ||||||
|  |   const users = await TestUser.getInstances({ | ||||||
|  |     status: { $ne: 'active' } | ||||||
|  |   }); | ||||||
|  |   expect(users.length).toEqual(2); | ||||||
|  |   const statuses = users.map(u => u.status).sort(); | ||||||
|  |   expect(statuses).toEqual(['inactive', 'pending']); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | tap.test('should filter using multiple comparison operators', async () => { | ||||||
|  |   const users = await TestUser.getInstances({ | ||||||
|  |     age: { $gte: 25, $lt: 30 } | ||||||
|  |   }); | ||||||
|  |   expect(users.length).toEqual(2); | ||||||
|  |   const names = users.map(u => u.name).sort(); | ||||||
|  |   expect(names).toEqual(['Alice Brown', 'Jane Smith']); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // ============= ARRAY OPERATOR TESTS ============= | ||||||
|  | tap.test('should filter using $in operator', async () => { | ||||||
|  |   const users = await TestUser.getInstances({ | ||||||
|  |     status: { $in: ['active', 'pending'] } | ||||||
|  |   }); | ||||||
|  |   expect(users.length).toEqual(4); | ||||||
|  |   expect(users.every(u => ['active', 'pending'].includes(u.status))).toEqual(true); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | tap.test('should filter arrays using $in operator', async () => { | ||||||
|  |   const users = await TestUser.getInstances({ | ||||||
|  |     roles: { $in: ['admin'] } | ||||||
|  |   }); | ||||||
|  |   expect(users.length).toEqual(2); | ||||||
|  |   const names = users.map(u => u.name).sort(); | ||||||
|  |   expect(names).toEqual(['Alice Brown', 'John Doe']); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | tap.test('should filter using $nin operator', async () => { | ||||||
|  |   const users = await TestUser.getInstances({ | ||||||
|  |     status: { $nin: ['inactive', 'pending'] } | ||||||
|  |   }); | ||||||
|  |   expect(users.length).toEqual(3); | ||||||
|  |   expect(users.every(u => u.status === 'active')).toEqual(true); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | tap.test('should filter arrays using $all operator', async () => { | ||||||
|  |   const users = await TestUser.getInstances({ | ||||||
|  |     tags: { $all: ['javascript', 'nodejs'] } | ||||||
|  |   }); | ||||||
|  |   expect(users.length).toEqual(2); | ||||||
|  |   const names = users.map(u => u.name).sort(); | ||||||
|  |   expect(names).toEqual(['Bob Johnson', 'John Doe']); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | tap.test('should filter arrays using $size operator', async () => { | ||||||
|  |   const users = await TestUser.getInstances({ | ||||||
|  |     scores: { $size: 2 } | ||||||
|  |   }); | ||||||
|  |   expect(users.length).toEqual(1); | ||||||
|  |   expect(users[0].name).toEqual('Charlie Wilson'); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | tap.test('should filter arrays using $elemMatch operator', async () => { | ||||||
|  |   const orders = await TestOrder.getInstances({ | ||||||
|  |     items: { | ||||||
|  |       $elemMatch: { | ||||||
|  |         product: 'laptop', | ||||||
|  |         quantity: { $gte: 2 } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   }); | ||||||
|  |   expect(orders.length).toEqual(1); | ||||||
|  |   expect(orders[0].totalAmount).toEqual(2560); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | tap.test('should filter using $elemMatch with single condition', async () => { | ||||||
|  |   const orders = await TestOrder.getInstances({ | ||||||
|  |     items: { | ||||||
|  |       $elemMatch: { | ||||||
|  |         price: { $gt: 100 } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   }); | ||||||
|  |   expect(orders.length).toEqual(2); | ||||||
|  |   expect(orders.every(o => o.items.some(i => i.price > 100))).toEqual(true); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // ============= LOGICAL OPERATOR TESTS ============= | ||||||
|  | tap.test('should filter using $or operator', async () => { | ||||||
|  |   const users = await TestUser.getInstances({ | ||||||
|  |     $or: [ | ||||||
|  |       { age: { $lt: 25 } }, | ||||||
|  |       { status: 'inactive' } | ||||||
|  |     ] | ||||||
|  |   }); | ||||||
|  |   expect(users.length).toEqual(2); | ||||||
|  |   const names = users.map(u => u.name).sort(); | ||||||
|  |   expect(names).toEqual(['Bob Johnson', 'Charlie Wilson']); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | tap.test('should filter using $and operator', async () => { | ||||||
|  |   const users = await TestUser.getInstances({ | ||||||
|  |     $and: [ | ||||||
|  |       { status: 'active' }, | ||||||
|  |       { age: { $gte: 28 } } | ||||||
|  |     ] | ||||||
|  |   }); | ||||||
|  |   expect(users.length).toEqual(2); | ||||||
|  |   const names = users.map(u => u.name).sort(); | ||||||
|  |   expect(names).toEqual(['Alice Brown', 'John Doe']); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | tap.test('should filter using $nor operator', async () => { | ||||||
|  |   const users = await TestUser.getInstances({ | ||||||
|  |     $nor: [ | ||||||
|  |       { status: 'inactive' }, | ||||||
|  |       { age: { $lt: 25 } } | ||||||
|  |     ] | ||||||
|  |   }); | ||||||
|  |   expect(users.length).toEqual(3); | ||||||
|  |   expect(users.every(u => u.status !== 'inactive' && u.age >= 25)).toEqual(true); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | tap.test('should filter using nested logical operators', async () => { | ||||||
|  |   const users = await TestUser.getInstances({ | ||||||
|  |     $or: [ | ||||||
|  |       { | ||||||
|  |         $and: [ | ||||||
|  |           { status: 'active' }, | ||||||
|  |           { roles: { $in: ['admin'] } } | ||||||
|  |         ] | ||||||
|  |       }, | ||||||
|  |       { age: { $lt: 23 } } | ||||||
|  |     ] | ||||||
|  |   }); | ||||||
|  |   expect(users.length).toEqual(3); | ||||||
|  |   const names = users.map(u => u.name).sort(); | ||||||
|  |   expect(names).toEqual(['Alice Brown', 'Charlie Wilson', 'John Doe']); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // ============= ELEMENT OPERATOR TESTS ============= | ||||||
|  | tap.test('should filter using $exists operator', async () => { | ||||||
|  |   const users = await TestUser.getInstances({ | ||||||
|  |     'metadata.lastLogin': { $exists: true } | ||||||
|  |   }); | ||||||
|  |   expect(users.length).toEqual(1); | ||||||
|  |   expect(users[0].name).toEqual('John Doe'); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | tap.test('should filter using $exists false', async () => { | ||||||
|  |   const users = await TestUser.getInstances({ | ||||||
|  |     'metadata.preferences': { $exists: false } | ||||||
|  |   }); | ||||||
|  |   expect(users.length).toEqual(5); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // ============= COMPLEX FILTER TESTS ============= | ||||||
|  | tap.test('should handle complex nested filters', async () => { | ||||||
|  |   const users = await TestUser.getInstances({ | ||||||
|  |     $and: [ | ||||||
|  |       { status: 'active' }, | ||||||
|  |       { | ||||||
|  |         $or: [ | ||||||
|  |           { age: { $gte: 30 } }, | ||||||
|  |           { roles: { $all: ['admin'] } } | ||||||
|  |         ] | ||||||
|  |       }, | ||||||
|  |       { tags: { $in: ['mongodb'] } } | ||||||
|  |     ] | ||||||
|  |   }); | ||||||
|  |   expect(users.length).toEqual(2); | ||||||
|  |   const names = users.map(u => u.name).sort(); | ||||||
|  |   expect(names).toEqual(['Alice Brown', 'John Doe']); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | tap.test('should combine multiple operator types', async () => { | ||||||
|  |   const orders = await TestOrder.getInstances({ | ||||||
|  |     $and: [ | ||||||
|  |       { totalAmount: { $gte: 100 } }, | ||||||
|  |       { status: { $in: ['completed', 'processing'] } }, | ||||||
|  |       { tags: { $in: ['electronics'] } } | ||||||
|  |     ] | ||||||
|  |   }); | ||||||
|  |   expect(orders.length).toEqual(2); | ||||||
|  |   expect(orders.every(o => o.totalAmount >= 100)).toEqual(true); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // ============= ERROR HANDLING TESTS ============= | ||||||
|  | tap.test('should throw error for $where operator', async () => { | ||||||
|  |   let error: Error | null = null; | ||||||
|  |   try { | ||||||
|  |     await TestUser.getInstances({ | ||||||
|  |       $where: 'this.age > 25' | ||||||
|  |     }); | ||||||
|  |   } catch (e) { | ||||||
|  |     error = e as Error; | ||||||
|  |   } | ||||||
|  |   expect(error).toBeTruthy(); | ||||||
|  |   expect(error?.message).toMatch(/\$where.*not allowed/); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | tap.test('should throw error for invalid $in value', async () => { | ||||||
|  |   let error: Error | null = null; | ||||||
|  |   try { | ||||||
|  |     await TestUser.getInstances({ | ||||||
|  |       status: { $in: 'active' as any } // Should be an array | ||||||
|  |     }); | ||||||
|  |   } catch (e) { | ||||||
|  |     error = e as Error; | ||||||
|  |   } | ||||||
|  |   expect(error).toBeTruthy(); | ||||||
|  |   expect(error?.message).toMatch(/\$in.*requires.*array/); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | tap.test('should throw error for invalid $size value', async () => { | ||||||
|  |   let error: Error | null = null; | ||||||
|  |   try { | ||||||
|  |     await TestUser.getInstances({ | ||||||
|  |       scores: { $size: '3' as any } // Should be a number | ||||||
|  |     }); | ||||||
|  |   } catch (e) { | ||||||
|  |     error = e as Error; | ||||||
|  |   } | ||||||
|  |   expect(error).toBeTruthy(); | ||||||
|  |   expect(error?.message).toMatch(/\$size.*requires.*numeric/); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | tap.test('should throw error for dots in field names', async () => { | ||||||
|  |   let error: Error | null = null; | ||||||
|  |   try { | ||||||
|  |     await TestUser.getInstances({ | ||||||
|  |       'some.nested.field': { 'invalid.key': 'value' } | ||||||
|  |     }); | ||||||
|  |   } catch (e) { | ||||||
|  |     error = e as Error; | ||||||
|  |   } | ||||||
|  |   expect(error).toBeTruthy(); | ||||||
|  |   expect(error?.message).toMatch(/keys cannot contain dots/); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // ============= EDGE CASE TESTS ============= | ||||||
|  | tap.test('should handle empty filter (return all)', async () => { | ||||||
|  |   const users = await TestUser.getInstances({}); | ||||||
|  |   expect(users.length).toEqual(5); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | tap.test('should handle null values in filter', async () => { | ||||||
|  |   // First, create a user with null email | ||||||
|  |   const nullUser = new TestUser({ | ||||||
|  |     name: 'Null User', | ||||||
|  |     age: 40, | ||||||
|  |     email: null as any, | ||||||
|  |     roles: ['user'], | ||||||
|  |     tags: [], | ||||||
|  |     status: 'active', | ||||||
|  |     metadata: {}, | ||||||
|  |     scores: [] | ||||||
|  |   }); | ||||||
|  |   await nullUser.save(); | ||||||
|  |  | ||||||
|  |   const users = await TestUser.getInstances({ email: null }); | ||||||
|  |   expect(users.length).toEqual(1); | ||||||
|  |   expect(users[0].name).toEqual('Null User'); | ||||||
|  |  | ||||||
|  |   // Clean up | ||||||
|  |   await nullUser.delete(); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | tap.test('should handle arrays as direct equality match', async () => { | ||||||
|  |   // This tests that arrays without operators are treated as equality matches | ||||||
|  |   const users = await TestUser.getInstances({ | ||||||
|  |     roles: ['user'] // Exact match for array | ||||||
|  |   }); | ||||||
|  |   expect(users.length).toEqual(2);  // Both Jane and Charlie have exactly ['user'] | ||||||
|  |   const names = users.map(u => u.name).sort(); | ||||||
|  |   expect(names).toEqual(['Charlie Wilson', 'Jane Smith']); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | tap.test('should handle regex operator', async () => { | ||||||
|  |   const users = await TestUser.getInstances({ | ||||||
|  |     name: { $regex: '^J', $options: 'i' } | ||||||
|  |   }); | ||||||
|  |   expect(users.length).toEqual(2); | ||||||
|  |   const names = users.map(u => u.name).sort(); | ||||||
|  |   expect(names).toEqual(['Jane Smith', 'John Doe']); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | tap.test('should handle unknown operators by letting MongoDB reject them', async () => { | ||||||
|  |   // Unknown operators should be passed through to MongoDB, which will reject them | ||||||
|  |   let error: Error | null = null; | ||||||
|  |    | ||||||
|  |   try { | ||||||
|  |     await TestUser.getInstances({ | ||||||
|  |       age: { $unknownOp: 30 } as any | ||||||
|  |     }); | ||||||
|  |   } catch (e) { | ||||||
|  |     error = e as Error; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   expect(error).toBeTruthy(); | ||||||
|  |   expect(error?.message).toMatch(/unknown operator.*\$unknownOp/); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // ============= PERFORMANCE TESTS ============= | ||||||
|  | tap.test('should efficiently filter large result sets', async () => { | ||||||
|  |   // Create many test documents | ||||||
|  |   const manyUsers = []; | ||||||
|  |   for (let i = 0; i < 100; i++) { | ||||||
|  |     manyUsers.push(new TestUser({ | ||||||
|  |       name: `User ${i}`, | ||||||
|  |       age: 20 + (i % 40), | ||||||
|  |       email: `user${i}@example.com`, | ||||||
|  |       roles: i % 3 === 0 ? ['admin'] : ['user'], | ||||||
|  |       tags: i % 2 === 0 ? ['even', 'test'] : ['odd', 'test'], | ||||||
|  |       status: i % 4 === 0 ? 'inactive' : 'active', | ||||||
|  |       metadata: { loginCount: i }, | ||||||
|  |       scores: [i, i + 10, i + 20] | ||||||
|  |     })); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // Save in batches for efficiency | ||||||
|  |   for (const user of manyUsers) { | ||||||
|  |     await user.save(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // Complex filter that should still be fast | ||||||
|  |   const startTime = Date.now(); | ||||||
|  |   const filtered = await TestUser.getInstances({ | ||||||
|  |     $and: [ | ||||||
|  |       { age: { $gte: 30, $lt: 40 } }, | ||||||
|  |       { status: 'active' }, | ||||||
|  |       { tags: { $in: ['even'] } }, | ||||||
|  |       { 'metadata.loginCount': { $gte: 20 } } | ||||||
|  |     ] | ||||||
|  |   }); | ||||||
|  |   const duration = Date.now() - startTime; | ||||||
|  |  | ||||||
|  |   console.log(`Complex filter on 100+ documents took ${duration}ms`); | ||||||
|  |   expect(duration).toBeLessThan(1000); // Should complete in under 1 second | ||||||
|  |   expect(filtered.length).toBeGreaterThan(0); | ||||||
|  |  | ||||||
|  |   // Clean up | ||||||
|  |   for (const user of manyUsers) { | ||||||
|  |     await user.delete(); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // ============= CLEANUP ============= | ||||||
|  | tap.test('should clean up test database', async () => { | ||||||
|  |   await testDb.mongoDb.dropDatabase(); | ||||||
|  |   await testDb.close(); | ||||||
|  |   await smartmongoInstance.stop(); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | export default tap.start(); | ||||||
| @@ -151,9 +151,49 @@ export function index(options?: IIndexOptions) { | |||||||
|   }; |   }; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | // Type that allows MongoDB operators on leaf values while maintaining nested type safety | ||||||
|  | export type MongoFilterCondition<T> = T | { | ||||||
|  |   $eq?: T; | ||||||
|  |   $ne?: T; | ||||||
|  |   $gt?: T; | ||||||
|  |   $gte?: T; | ||||||
|  |   $lt?: T; | ||||||
|  |   $lte?: T; | ||||||
|  |   $in?: T extends (infer U)[] ? U[] | U : T[]; | ||||||
|  |   $nin?: T extends (infer U)[] ? U[] | U : T[]; | ||||||
|  |   $exists?: boolean; | ||||||
|  |   $type?: string | number; | ||||||
|  |   $regex?: string | RegExp; | ||||||
|  |   $options?: string; | ||||||
|  |   $all?: T extends (infer U)[] ? U[] : never; | ||||||
|  |   $elemMatch?: T extends (infer U)[] ? MongoFilter<U> : never; | ||||||
|  |   $size?: T extends any[] ? number : never; | ||||||
|  |   $not?: MongoFilterCondition<T>; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export type MongoFilter<T> = { | ||||||
|  |   [K in keyof T]?: T[K] extends object  | ||||||
|  |     ? T[K] extends any[] | ||||||
|  |       ? MongoFilterCondition<T[K]>  // Arrays can have operators | ||||||
|  |       : MongoFilter<T[K]> | MongoFilterCondition<T[K]>  // Objects can be nested or have operators | ||||||
|  |     : MongoFilterCondition<T[K]>;  // Primitives get operators | ||||||
|  | } & { | ||||||
|  |   // Logical operators | ||||||
|  |   $and?: MongoFilter<T>[]; | ||||||
|  |   $or?: MongoFilter<T>[]; | ||||||
|  |   $nor?: MongoFilter<T>[]; | ||||||
|  |   $not?: MongoFilter<T>; | ||||||
|  |   // Allow any string key for dot notation (we lose type safety here but maintain flexibility) | ||||||
|  |   [key: string]: any; | ||||||
|  | }; | ||||||
|  |  | ||||||
| export const convertFilterForMongoDb = (filterArg: { [key: string]: any }) => { | export const convertFilterForMongoDb = (filterArg: { [key: string]: any }) => { | ||||||
|  |   // SECURITY: Block $where to prevent server-side JS execution | ||||||
|  |   if (filterArg.$where !== undefined) { | ||||||
|  |     throw new Error('$where operator is not allowed for security reasons'); | ||||||
|  |   } | ||||||
|  |    | ||||||
|   // Special case: detect MongoDB operators and pass them through directly |   // Special case: detect MongoDB operators and pass them through directly | ||||||
|   // SECURITY: Removed $where to prevent server-side JS execution |  | ||||||
|   const topLevelOperators = ['$and', '$or', '$nor', '$not', '$text', '$regex']; |   const topLevelOperators = ['$and', '$or', '$nor', '$not', '$text', '$regex']; | ||||||
|   for (const key of Object.keys(filterArg)) { |   for (const key of Object.keys(filterArg)) { | ||||||
|     if (topLevelOperators.includes(key)) { |     if (topLevelOperators.includes(key)) { | ||||||
| @@ -166,26 +206,76 @@ export const convertFilterForMongoDb = (filterArg: { [key: string]: any }) => { | |||||||
|  |  | ||||||
|   const convertFilterArgument = (keyPathArg2: string, filterArg2: any) => { |   const convertFilterArgument = (keyPathArg2: string, filterArg2: any) => { | ||||||
|     if (Array.isArray(filterArg2)) { |     if (Array.isArray(filterArg2)) { | ||||||
|       // FIX: Properly handle arrays for operators like $in, $all, or plain equality |       // Arrays are typically used as values for operators like $in or as direct equality matches | ||||||
|       convertedFilter[keyPathArg2] = filterArg2; |       convertedFilter[keyPathArg2] = filterArg2; | ||||||
|       return; |       return; | ||||||
|     } else if (typeof filterArg2 === 'object' && filterArg2 !== null) { |     } else if (typeof filterArg2 === 'object' && filterArg2 !== null) { | ||||||
|       for (const key of Object.keys(filterArg2)) { |       // Check if this is an object with MongoDB operators | ||||||
|         if (key.startsWith('$')) { |       const keys = Object.keys(filterArg2); | ||||||
|           // Prevent dangerous operators |       const hasOperators = keys.some(key => key.startsWith('$')); | ||||||
|           if (key === '$where') { |        | ||||||
|             throw new Error('$where operator is not allowed for security reasons'); |       if (hasOperators) { | ||||||
|           } |         // This object contains MongoDB operators | ||||||
|           convertedFilter[keyPathArg2] = filterArg2; |         // Validate and pass through allowed operators | ||||||
|           return; |         const allowedOperators = [ | ||||||
|         } else if (key.includes('.')) { |           // Comparison operators | ||||||
|  |           '$eq', '$ne', '$gt', '$gte', '$lt', '$lte', | ||||||
|  |           // Array operators | ||||||
|  |           '$in', '$nin', '$all', '$elemMatch', '$size', | ||||||
|  |           // Element operators | ||||||
|  |           '$exists', '$type', | ||||||
|  |           // Evaluation operators (safe ones only) | ||||||
|  |           '$regex', '$options', '$text', '$mod', | ||||||
|  |           // Logical operators (nested) | ||||||
|  |           '$and', '$or', '$nor', '$not' | ||||||
|  |         ]; | ||||||
|  |          | ||||||
|  |         // Check for dangerous operators | ||||||
|  |         if (keys.includes('$where')) { | ||||||
|  |           throw new Error('$where operator is not allowed for security reasons'); | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         // Validate all operators are in the allowed list | ||||||
|  |         const invalidOperators = keys.filter(key =>  | ||||||
|  |           key.startsWith('$') && !allowedOperators.includes(key) | ||||||
|  |         ); | ||||||
|  |          | ||||||
|  |         if (invalidOperators.length > 0) { | ||||||
|  |           console.warn(`Warning: Unknown MongoDB operators detected: ${invalidOperators.join(', ')}`); | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         // For array operators, ensure the values are appropriate | ||||||
|  |         if (filterArg2.$in && !Array.isArray(filterArg2.$in)) { | ||||||
|  |           throw new Error('$in operator requires an array value'); | ||||||
|  |         } | ||||||
|  |         if (filterArg2.$nin && !Array.isArray(filterArg2.$nin)) { | ||||||
|  |           throw new Error('$nin operator requires an array value'); | ||||||
|  |         } | ||||||
|  |         if (filterArg2.$all && !Array.isArray(filterArg2.$all)) { | ||||||
|  |           throw new Error('$all operator requires an array value'); | ||||||
|  |         } | ||||||
|  |         if (filterArg2.$size && typeof filterArg2.$size !== 'number') { | ||||||
|  |           throw new Error('$size operator requires a numeric value'); | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         // Pass the operator object through | ||||||
|  |         convertedFilter[keyPathArg2] = filterArg2; | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |        | ||||||
|  |       // No operators, check for dots in keys | ||||||
|  |       for (const key of keys) { | ||||||
|  |         if (key.includes('.')) { | ||||||
|           throw new Error('keys cannot contain dots'); |           throw new Error('keys cannot contain dots'); | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|       for (const key of Object.keys(filterArg2)) { |        | ||||||
|  |       // Recursively process nested objects | ||||||
|  |       for (const key of keys) { | ||||||
|         convertFilterArgument(`${keyPathArg2}.${key}`, filterArg2[key]); |         convertFilterArgument(`${keyPathArg2}.${key}`, filterArg2[key]); | ||||||
|       } |       } | ||||||
|     } else { |     } else { | ||||||
|  |       // Primitive values | ||||||
|       convertedFilter[keyPathArg2] = filterArg2; |       convertedFilter[keyPathArg2] = filterArg2; | ||||||
|     } |     } | ||||||
|   }; |   }; | ||||||
| @@ -227,12 +317,12 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends | |||||||
|   /** |   /** | ||||||
|    * gets all instances as array |    * gets all instances as array | ||||||
|    * @param this |    * @param this | ||||||
|    * @param filterArg |    * @param filterArg - Type-safe MongoDB filter with nested object support and operators | ||||||
|    * @returns |    * @returns | ||||||
|    */ |    */ | ||||||
|   public static async getInstances<T>( |   public static async getInstances<T>( | ||||||
|     this: plugins.tsclass.typeFest.Class<T>, |     this: plugins.tsclass.typeFest.Class<T>, | ||||||
|     filterArg: plugins.tsclass.typeFest.PartialDeep<T>, |     filterArg: MongoFilter<T>, | ||||||
|     opts?: { session?: plugins.mongodb.ClientSession } |     opts?: { session?: plugins.mongodb.ClientSession } | ||||||
|   ): Promise<T[]> { |   ): Promise<T[]> { | ||||||
|     // Pass session through to findAll for transactional queries |     // Pass session through to findAll for transactional queries | ||||||
| @@ -256,7 +346,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends | |||||||
|    */ |    */ | ||||||
|   public static async getInstance<T>( |   public static async getInstance<T>( | ||||||
|     this: plugins.tsclass.typeFest.Class<T>, |     this: plugins.tsclass.typeFest.Class<T>, | ||||||
|     filterArg: plugins.tsclass.typeFest.PartialDeep<T>, |     filterArg: MongoFilter<T>, | ||||||
|     opts?: { session?: plugins.mongodb.ClientSession } |     opts?: { session?: plugins.mongodb.ClientSession } | ||||||
|   ): Promise<T> { |   ): Promise<T> { | ||||||
|     // Retrieve one document, with optional session for transactions |     // Retrieve one document, with optional session for transactions | ||||||
| @@ -289,7 +379,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends | |||||||
|    */ |    */ | ||||||
|   public static async getCursor<T>( |   public static async getCursor<T>( | ||||||
|     this: plugins.tsclass.typeFest.Class<T>, |     this: plugins.tsclass.typeFest.Class<T>, | ||||||
|     filterArg: plugins.tsclass.typeFest.PartialDeep<T>, |     filterArg: MongoFilter<T>, | ||||||
|     opts?: { |     opts?: { | ||||||
|       session?: plugins.mongodb.ClientSession; |       session?: plugins.mongodb.ClientSession; | ||||||
|       modifier?: (cursorArg: plugins.mongodb.FindCursor<plugins.mongodb.WithId<plugins.mongodb.BSON.Document>>) => plugins.mongodb.FindCursor<plugins.mongodb.WithId<plugins.mongodb.BSON.Document>>; |       modifier?: (cursorArg: plugins.mongodb.FindCursor<plugins.mongodb.WithId<plugins.mongodb.BSON.Document>>) => plugins.mongodb.FindCursor<plugins.mongodb.WithId<plugins.mongodb.BSON.Document>>; | ||||||
| @@ -319,7 +409,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends | |||||||
|    */ |    */ | ||||||
|   public static async watch<T>( |   public static async watch<T>( | ||||||
|     this: plugins.tsclass.typeFest.Class<T>, |     this: plugins.tsclass.typeFest.Class<T>, | ||||||
|     filterArg: plugins.tsclass.typeFest.PartialDeep<T>, |     filterArg: MongoFilter<T>, | ||||||
|     opts?: plugins.mongodb.ChangeStreamOptions & { bufferTimeMs?: number }, |     opts?: plugins.mongodb.ChangeStreamOptions & { bufferTimeMs?: number }, | ||||||
|   ): Promise<SmartdataDbWatcher<T>> { |   ): Promise<SmartdataDbWatcher<T>> { | ||||||
|     const collection: SmartdataCollection<T> = (this as any).collection; |     const collection: SmartdataCollection<T> = (this as any).collection; | ||||||
| @@ -337,7 +427,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends | |||||||
|    */ |    */ | ||||||
|   public static async forEach<T>( |   public static async forEach<T>( | ||||||
|     this: plugins.tsclass.typeFest.Class<T>, |     this: plugins.tsclass.typeFest.Class<T>, | ||||||
|     filterArg: plugins.tsclass.typeFest.PartialDeep<T>, |     filterArg: MongoFilter<T>, | ||||||
|     forEachFunction: (itemArg: T) => Promise<any>, |     forEachFunction: (itemArg: T) => Promise<any>, | ||||||
|   ) { |   ) { | ||||||
|     const cursor: SmartdataDbCursor<T> = await (this as any).getCursor(filterArg); |     const cursor: SmartdataDbCursor<T> = await (this as any).getCursor(filterArg); | ||||||
| @@ -349,7 +439,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends | |||||||
|    */ |    */ | ||||||
|   public static async getCount<T>( |   public static async getCount<T>( | ||||||
|     this: plugins.tsclass.typeFest.Class<T>, |     this: plugins.tsclass.typeFest.Class<T>, | ||||||
|     filterArg: plugins.tsclass.typeFest.PartialDeep<T> = {} as any, |     filterArg: MongoFilter<T> = {} as any, | ||||||
|   ) { |   ) { | ||||||
|     const collection: SmartdataCollection<T> = (this as any).collection; |     const collection: SmartdataCollection<T> = (this as any).collection; | ||||||
|     return await collection.getCount(filterArg); |     return await collection.getCount(filterArg); | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user