import { IBaseMongoRepository } from '@/core/domain/interfaces/base.mongo.repository.interface'; import { ObjectId } from 'mongodb'; import { PaginateQuery, Paginated } from 'nestjs-paginate'; import { EntityManager, MongoRepository, ObjectLiteral } from 'typeorm'; import { ExtendedPaginateConfig } from './base.mongo.entity.interface'; /** * Represents a generic MongoDB repository for managing entities. */ export class BaseMongoRepository extends MongoRepository implements IBaseMongoRepository { public paginateConfig: ExtendedPaginateConfig; constructor(manager: EntityManager, entityCls: new () => E, paginateConfig?: ExtendedPaginateConfig) { super(entityCls, manager); this.paginateConfig = paginateConfig || { sortableColumns: [], }; } /** * Retrieves a paginated list of entities based on the provided query. * * @param query - The pagination query parameters. * @returns A promise that resolves to a paginated list of entities. */ async getMany(query: PaginateQuery): Promise> { //this implementation of pagination is considering skip and limit and not cursors const page = query.page ?? 1; //get the limit (combining config limits and query limits) const defaultLimit = this.paginateConfig.defaultLimit ?? 20; const maxLimit = this.paginateConfig.maxLimit ?? 100; const inputLimit = query.limit ?? defaultLimit; const limit = Math.min(inputLimit, maxLimit); const skip = (page - 1) * limit; // building complex filters (where clause) const filter = this.buildMongoFilter(query, this.paginateConfig); // building sort clause (multiple fields) const sort = this.buildMongoSort(query); // selecting fields (projection) const selectCondition = this.buildMongoSelect(query, this.paginateConfig); // fetching data and total count const [data, totalItems] = await this.findAndCount({ where: filter as any, order: sort as any, skip, take: limit, select: selectCondition, }); const totalPages = Math.ceil(totalItems / limit); const meta = { itemsPerPage: limit, totalItems, currentPage: page, totalPages, // cast these to any to avoid the extremely strict SortBy/E typings from nestjs-paginate sortBy: (query.sortBy ?? []) as unknown as any, searchBy: (query.searchBy ?? []) as unknown as any, search: query.search ?? '', select: selectCondition ?? [], filter: query.filter ?? {}, }; const links = { current: query.path ? `${query.path}?page=${page}&limit=${limit}` : '', first: query.path ? `${query.path}?page=1&limit=${limit}` : undefined, previous: page > 1 && query.path ? `${query.path}?page=${page - 1}&limit=${limit}` : undefined, next: page < totalPages && query.path ? `${query.path}?page=${page + 1}&limit=${limit}` : undefined, last: query.path ? `${query.path}?page=${totalPages}&limit=${limit}` : undefined, }; const plainData = data.map(d => JSON.parse(JSON.stringify(d))); // final cast: runtime structure matches Paginated, cast to satisfy TS return { data: plainData, meta: meta as unknown as Paginated['meta'], links: links as any, } as Paginated; } /** * Retrieves an entity from the database based on its ID. * * @param id - The ID of the entity to retrieve (string or ObjectId). * @returns A promise that resolves to the retrieved entity. */ async findById(id: string | ObjectId): Promise { let objectId: ObjectId; if (typeof id === 'string') { if (!ObjectId.isValid(id)) { throw new Error(`Invalid ObjectId string: ${id}`); } objectId = new ObjectId(id); } else if (id instanceof ObjectId) { objectId = id; } else { throw new Error(`Invalid id type: ${typeof id}`); } return this.findOne({ where: { _id: objectId }, }); } protected buildMongoFilter(query: PaginateQuery, paginateConfig: ExtendedPaginateConfig): Record { // apply filter whitelist from config const { filter = {} } = query; const filterConfig = paginateConfig.filterableColumns || {}; const allowedFilterColumns = new Set(Object.keys(filterConfig)); const dateCols = new Set(paginateConfig.dateColumns || []); // conditions have positional order on $or $and operators const orderedConditions: { field: string; ops: string[]; valuePart: string; isDate: boolean }[] = []; // operator whitelist for (const [field, rawValue] of Object.entries(filter)) { if (!allowedFilterColumns.has(field)) { console.warn(`Column filter not allowed: ${field}`); continue; } // dates needs to be parsed correctly (transported as strings but stored as Date) const isDate = dateCols.has(field); // array values like e.g. $in ['val1','val2'] if (Array.isArray(rawValue)) { for (const cond of rawValue) { if (typeof cond !== 'string') continue; const tokens = cond.split(':'); const ops = tokens.filter(t => t.startsWith('$')); const valuePart = tokens.slice(tokens.findIndex(t => !t.startsWith('$'))).join(':'); orderedConditions.push({ field, ops, valuePart, isDate }); } // every other case is a string } else if (typeof rawValue === 'string') { if (!rawValue.includes(':')) { // simple single operator orderedConditions.push({ field, ops: [], valuePart: rawValue, isDate }); } else { // multiple operators in a single string value e.g. $not:$in:value const tokens = rawValue.split(':'); const ops = tokens.filter(t => t.startsWith('$')); const valuePart = tokens.slice(tokens.findIndex(t => !t.startsWith('$'))).join(':'); orderedConditions.push({ field, ops, valuePart, isDate }); } } } // main AND group const andGroup: any[] = []; let lastCondition: any = null; for (const { field, ops, valuePart, isDate } of orderedConditions) { // remove the first logical operator if it is $or or $and and handle the others const mainLogical = ops[0] === '$or' || ops[0] === '$and' ? ops[0] : null; if (mainLogical) ops.shift(); // build logical condition for some special operators like $btw,$ilike... const special = this.buildSpecialOperator(ops, valuePart, isDate); let finalVal: any; if (special) { // handled by special operator builder finalVal = special; } else if (ops.length && ops[ops.length - 1] === '$in') { // handle $in and $nin with comma separated values const vals = valuePart.split(',').map(s => this.parsePrimitive(s.trim(), isDate)); finalVal = ops[0] === '$not' ? { $nin: vals } : { $in: vals }; } else { // primitive type like $eq with string, number, boolean, dates.. finalVal = this.parsePrimitive(valuePart, isDate); if (ops.length) { const temp: Record = {}; // apply remaining operators this.applyOps(temp, ops, finalVal); finalVal = temp; } } const condition: Record = { [field]: finalVal }; // $and, $or are applied backward to the previous condition positionally if ((mainLogical === '$or' || mainLogical === '$and') && lastCondition) { const prev = andGroup.pop(); const merged = { [mainLogical]: [prev, condition] }; andGroup.push(merged); lastCondition = merged; } else { // otherwise is a simple push to the main AND group andGroup.push(condition); lastCondition = condition; } } // final filtering conditions if (andGroup.length === 0) { return {}; } const finalFilter = andGroup.length === 1 ? andGroup[0] : { $and: andGroup }; //console.log('Final MongoDB filter:', JSON.stringify(finalFilter, null, 2)); return finalFilter; } // transform string field into real stored type (needed because reflection doesn't work with generics) private parsePrimitive(raw: string, isDateField: boolean): any { if (isDateField) { const d = new Date(raw); if (!isNaN(d.getTime())) return d; } if (raw === 'true') return true; if (raw === 'false') return false; if (raw !== '' && !Number.isNaN(Number(raw)) && !/^0\d+/.test(raw)) return Number(raw); return raw; } // helper to apply nested operators private applyOps(targetObj: Record, ops: string[], value: any): void { let ref = targetObj; for (let i = 0; i < ops.length; i++) { const op = ops[i]; const isLast = i === ops.length - 1; if (isLast) ref[op] = value; else { if (ref[op] === undefined) ref[op] = {}; ref = ref[op]; } } } // builds special operators like $btw, $sw, $ilike, $null, $contains.. (with regex support) private buildSpecialOperator(ops: string[], valuePart: string, isDate: boolean): Record | null { const isNegated = ops.includes('$not'); const cleanOps = ops.filter(o => o !== '$not'); const values = valuePart.split(',').map(v => this.parsePrimitive(v.trim(), isDate)); const asSafeRegexString = (val: any): string => { if (val == null) return ''; const s = String(val); return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }; switch (cleanOps[0]) { case '$btw': { const [from, to] = values; const expr = { $gte: from, $lte: to }; return isNegated ? { $not: expr } : expr; } case '$sw': { const regex = new RegExp(`^${asSafeRegexString(values[0])}`, 'i'); return isNegated ? { $not: regex } : { $regex: regex }; } case '$ilike': { const regex = new RegExp(asSafeRegexString(values[0]), 'i'); return isNegated ? { $not: regex } : { $regex: regex }; } case '$null': { return isNegated ? { $ne: null } : { $eq: null }; } case '$contains': { const expr = { $all: values }; return isNegated ? { $not: expr } : expr; } default: return null; } } // builds the sort object for MongoDB queries (multiple fields sorting) protected buildMongoSort(query: PaginateQuery): Record { const { sortBy = [] } = query; const sort: Record = {}; sortBy.forEach(([field, direction]) => { sort[field] = direction.toUpperCase() === 'ASC' ? 1 : -1; }); // default: _id DESC if (Object.keys(sort).length === 0) { sort['_id'] = -1; } return sort; } // builds the projection fields for MongoDB queries // for future use with cursor-based pagination protected buildMongoProjectionFields(query: PaginateQuery, cursorColumn = '_id'): string[] | undefined { const { select } = query; if (select && select.length) { const fields = [...select]; if (!fields.includes(cursorColumn)) { fields.push(cursorColumn); } return fields; } return undefined; } // normalizes the cursor value to string (for pagination links) // for future use with cursor-based pagination protected normalizeCursorValue(val: any): string { if (!val && val !== 0) return undefined; if (val instanceof ObjectId && typeof val.toHexString === 'function') { return val.toHexString(); } if (val && val._bsontype === 'ObjectID' && typeof val.toHexString === 'function') { return val.toHexString(); } return String(val); } // builds the query string for pagination links // for future use with cursor-based pagination protected buildQueryString(obj: Record): string { const params = new URLSearchParams(); for (const [k, v] of Object.entries(obj)) { if (v === undefined || v === null) continue; params.append(k, String(v)); } return params.toString(); } // filter the selected fields trough the whitelist coming from config protected buildMongoSelect(query: PaginateQuery, paginateConfig: ExtendedPaginateConfig): string[] | undefined { const requestedSelect = query.select; // no select requested -> no filtering needed if (!requestedSelect || requestedSelect.length === 0) { return undefined; } const allowedSelectColumns = new Set(paginateConfig.select || []); // if no allowed columns, warn and return undefined if (allowedSelectColumns.size === 0) { console.warn('no columns are allowed for selection in config'); // undefined means -> use typeorm default (all columns) return undefined; } // whitelist from config applied const validSelect = requestedSelect.filter(field => { const isAllowed = allowedSelectColumns.has(field); if (!isAllowed) { console.warn(`column not allowed: ${field}`); } return isAllowed; }); // if no valid fields remain after filtering, return undefined to avoid errors if (validSelect.length === 0) { return undefined; } return validSelect; } }