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:
@@ -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 }) => {
|
||||
// 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
|
||||
// SECURITY: Removed $where to prevent server-side JS execution
|
||||
const topLevelOperators = ['$and', '$or', '$nor', '$not', '$text', '$regex'];
|
||||
for (const key of Object.keys(filterArg)) {
|
||||
if (topLevelOperators.includes(key)) {
|
||||
@@ -166,26 +206,76 @@ export const convertFilterForMongoDb = (filterArg: { [key: string]: any }) => {
|
||||
|
||||
const convertFilterArgument = (keyPathArg2: string, filterArg2: any) => {
|
||||
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;
|
||||
return;
|
||||
} else if (typeof filterArg2 === 'object' && filterArg2 !== null) {
|
||||
for (const key of Object.keys(filterArg2)) {
|
||||
if (key.startsWith('$')) {
|
||||
// Prevent dangerous operators
|
||||
if (key === '$where') {
|
||||
throw new Error('$where operator is not allowed for security reasons');
|
||||
}
|
||||
convertedFilter[keyPathArg2] = filterArg2;
|
||||
return;
|
||||
} else if (key.includes('.')) {
|
||||
// Check if this is an object with MongoDB operators
|
||||
const keys = Object.keys(filterArg2);
|
||||
const hasOperators = keys.some(key => key.startsWith('$'));
|
||||
|
||||
if (hasOperators) {
|
||||
// This object contains MongoDB operators
|
||||
// Validate and pass through allowed operators
|
||||
const allowedOperators = [
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(filterArg2)) {
|
||||
|
||||
// Recursively process nested objects
|
||||
for (const key of keys) {
|
||||
convertFilterArgument(`${keyPathArg2}.${key}`, filterArg2[key]);
|
||||
}
|
||||
} else {
|
||||
// Primitive values
|
||||
convertedFilter[keyPathArg2] = filterArg2;
|
||||
}
|
||||
};
|
||||
@@ -227,12 +317,12 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
||||
/**
|
||||
* gets all instances as array
|
||||
* @param this
|
||||
* @param filterArg
|
||||
* @param filterArg - Type-safe MongoDB filter with nested object support and operators
|
||||
* @returns
|
||||
*/
|
||||
public static async getInstances<T>(
|
||||
this: plugins.tsclass.typeFest.Class<T>,
|
||||
filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
|
||||
filterArg: MongoFilter<T>,
|
||||
opts?: { session?: plugins.mongodb.ClientSession }
|
||||
): Promise<T[]> {
|
||||
// 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>(
|
||||
this: plugins.tsclass.typeFest.Class<T>,
|
||||
filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
|
||||
filterArg: MongoFilter<T>,
|
||||
opts?: { session?: plugins.mongodb.ClientSession }
|
||||
): Promise<T> {
|
||||
// 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>(
|
||||
this: plugins.tsclass.typeFest.Class<T>,
|
||||
filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
|
||||
filterArg: MongoFilter<T>,
|
||||
opts?: {
|
||||
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>>;
|
||||
@@ -319,7 +409,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
||||
*/
|
||||
public static async watch<T>(
|
||||
this: plugins.tsclass.typeFest.Class<T>,
|
||||
filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
|
||||
filterArg: MongoFilter<T>,
|
||||
opts?: plugins.mongodb.ChangeStreamOptions & { bufferTimeMs?: number },
|
||||
): Promise<SmartdataDbWatcher<T>> {
|
||||
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>(
|
||||
this: plugins.tsclass.typeFest.Class<T>,
|
||||
filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
|
||||
filterArg: MongoFilter<T>,
|
||||
forEachFunction: (itemArg: T) => Promise<any>,
|
||||
) {
|
||||
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>(
|
||||
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;
|
||||
return await collection.getCount(filterArg);
|
||||
|
Reference in New Issue
Block a user