import { type FindOptions, Op, type OrderItem, Sequelize, type WhereOptions } from "sequelize"; import type { Criteria } from "./critera"; import type { ConvertParams, CriteriaMappings, ICriteriaToOrmConverter } from "./types"; import { appendOrder, prependOrder } from "./utils"; /** * Conversor optimizado Criteria → Sequelize FindOptions */ export class CriteriaToSequelizeConverter implements ICriteriaToOrmConverter { convert(criteria: Criteria, params: ConvertParams = {}): FindOptions { const options: FindOptions = params.baseOptions ? { ...params.baseOptions } : {}; const { mappings = {} } = params; this.applyFilters(options, criteria, mappings); this.applyQuickSearch(options, criteria, params); this.applyOrder(options, criteria, mappings, params); this.applyPagination(options, criteria); const normalizedOrder = Array.isArray(options.order) ? options.order : options.order ? [options.order] : []; const normalizedInclude = Array.isArray(options.include) ? options.include : options.include ? [options.include] : []; return { ...options, order: normalizedOrder, include: normalizedInclude, }; } /** Filtros simples (sin anidaciones complejas) */ public applyFilters(options: FindOptions, criteria: Criteria, mappings: CriteriaMappings) { if (!criteria.hasFilters()) return; const filters: WhereOptions[] = []; for (const filter of criteria.filters.value) { const field = mappings[filter.field.value] || filter.field.value; const operator = this.mapOperator(filter.operator.value); const value = this.transformValue(operator, filter.value.value); filters.push({ [field]: { [operator]: value } }); } options.where = options.where ? { [Op.and]: [options.where, ...filters] } : { [Op.and]: filters }; } /** QuickSearch seguro con validación FULLTEXT */ public applyQuickSearch(options: FindOptions, criteria: Criteria, params: ConvertParams): void { const { mappings = {}, searchableFields = [], database, enableFullText = false, } = params as ConvertParams & { database?: Sequelize }; const term = typeof criteria.quickSearch === "string" ? criteria.quickSearch.trim() : ""; if (!term || searchableFields.length === 0 || !enableFullText) return; // Validación defensiva if (!database) { const msg = `[CriteriaToSequelizeConverter] enableFullText=true pero falta 'database' en params.`; if (params.strictMode) throw new Error(msg); console.warn(msg); return; } const booleanTerm = term .split(/\s+/) .map((w) => `+${w}*`) .join(" "); const mappedFields = searchableFields.map((f) => mappings[f] || f); const matchExpr = `MATCH(${mappedFields.join(", ")}) AGAINST (${database.escape( booleanTerm )} IN BOOLEAN MODE)`; const matchLiteral = Sequelize.literal(matchExpr); // Añadir campo virtual "score" if (!options.attributes) options.attributes = { include: [] }; if (Array.isArray(options.attributes)) { options.attributes.push([matchLiteral, "score"]); } else { options.attributes.include = options.attributes.include || []; options.attributes.include.push([matchLiteral, "score"]); } // WHERE score > 0 const scoreCondition = Sequelize.where(matchLiteral, { [Op.gt]: 0 }); options.where = options.where ? { [Op.and]: [options.where, scoreCondition] } : { [Op.and]: [scoreCondition] }; // Ordenar por relevancia prependOrder(options, [Sequelize.literal("score"), "DESC"]); } /** Ordenación validada y parametrizable */ public applyOrder( options: FindOptions, criteria: Criteria, mappings: CriteriaMappings, params: ConvertParams ): void { if (!criteria.hasOrder()) return; const field = mappings[criteria.order.orderBy.value] || criteria.order.orderBy.value; const direction = criteria.order.orderType.value.toUpperCase(); const allowedFields = params.allowedFields ?? ["id", "created_at"]; const strict = params.strictMode ?? false; if (!allowedFields.includes(field)) { const msg = `[CriteriaToSequelizeConverter] Ignored ORDER BY '${field}' (not in allowedFields).`; if (strict) throw new Error(msg); console.warn(msg); return; } appendOrder(options, [[field, direction]] as OrderItem[]); } /** Paginación estándar */ public applyPagination(options: FindOptions, criteria: Criteria): void { if (criteria.pageSize != null) { options.limit = criteria.pageSize; if (criteria.pageNumber != null) { options.offset = criteria.pageSize * criteria.pageNumber; } } } /** Mapeo de operadores Criteria → Sequelize */ private mapOperator(operator: string): symbol { switch (operator) { case "CONTAINS": return Op.like; case "NOT_CONTAINS": return Op.notLike; case "NOT_EQUALS": return Op.ne; case "GREATER_THAN": return Op.gt; case "GREATER_THAN_OR_EQUAL": return Op.gte; case "LOWER_THAN": return Op.lt; case "LOWER_THAN_OR_EQUAL": return Op.lte; case "EQUALS": return Op.eq; default: return Op.eq; } } private transformValue(operator: symbol, value: unknown): unknown { if (operator === Op.like || operator === Op.notLike) return `%${value}%`; return value; } }