import { type FindOptions, Op, type OrderItem, Sequelize, type WhereOptions } from "sequelize"; import type { Criteria } from "./critera.js"; import type { ConvertParams, CriteriaFieldMapping, CriteriaMappings, ICriteriaToOrmConverter, } from "./types.js"; import { buildBooleanFullTextQuery } from "./build-boolean-full-text-query.js"; import { appendOrder, normalizeOrder, prependOrder } from "./utils"; /** * Conversor Criteria → Sequelize FindOptions. * * Mantiene el converter limitado a criterios genéricos: * filtros, búsqueda rápida, orden y paginación. * * La composición de restricciones de agregado, includes técnicos y transacción * debe vivir en el repositorio. */ export class CriteriaToSequelizeConverter implements ICriteriaToOrmConverter { public convert(criteria: Criteria, params: ConvertParams = {}): FindOptions { const options: FindOptions = {}; const mappings = params.mappings ?? {}; this.applyFilters(options, criteria, mappings); this.applyQuickSearch(options, criteria, params); this.applyOrder(options, criteria, mappings, params); this.applyPagination(options, criteria); return { ...options, order: normalizeOrder(options.order), }; } public applyFilters(options: FindOptions, criteria: Criteria, mappings: CriteriaMappings): void { if (!criteria.hasFilters()) return; const filters: WhereOptions[] = []; for (const filter of criteria.filters.value) { const logicalField = filter.field.value; const mapping = this.resolveMapping(logicalField, mappings); const operator = this.mapOperator(filter.operator.value); const value = this.transformValue(operator, filter.value.value); filters.push(this.buildWhereCondition(mapping, operator, value)); } options.where = options.where ? { [Op.and]: [options.where, ...filters] } : { [Op.and]: filters }; } public applyQuickSearch(options: FindOptions, criteria: Criteria, params: ConvertParams): void { const { searchableFields = [], database, enableFullText = false, fullTextTableAlias } = params; const term = typeof criteria.quickSearch === "string" ? criteria.quickSearch.trim() : ""; if (!term || searchableFields.length === 0 || !enableFullText) return; if (!database) { const msg = "[CriteriaToSequelizeConverter] enableFullText=true pero falta 'database'."; if (params.strictMode) throw new Error(msg); console.warn(msg); return; } const booleanTerm = buildBooleanFullTextQuery(term); if (!booleanTerm) return; const qualifiedFields = searchableFields.map((field) => this.qualifyRootColumn(field, fullTextTableAlias) ); const matchExpr = `MATCH(${qualifiedFields.join(", ")}) AGAINST (${database.escape( booleanTerm )} IN BOOLEAN MODE)`; const matchLiteral = Sequelize.literal(matchExpr); this.includeAttribute(options, matchLiteral, "score"); const scoreCondition = Sequelize.where(matchLiteral, { [Op.gt]: 0 }); options.where = options.where ? { [Op.and]: [options.where, scoreCondition] } : { [Op.and]: [scoreCondition] }; prependOrder(options, [Sequelize.literal("score"), "DESC"]); } public applyOrder( options: FindOptions, criteria: Criteria, mappings: CriteriaMappings, params: ConvertParams ): void { if (!criteria.hasOrder()) return; const logicalField = criteria.order.orderBy.value; const direction = criteria.order.orderType.value.toUpperCase(); this.assertSortableField(logicalField, params); const mapping = this.resolveMapping(logicalField, mappings); const orderItem = this.buildOrderItem(mapping, direction); appendOrder(options, orderItem); } public applyPagination(options: FindOptions, criteria: Criteria): void { if (criteria.pageSize == null) return; options.limit = criteria.pageSize; if (criteria.pageNumber != null) { options.offset = criteria.pageSize * criteria.pageNumber; } } private resolveMapping(logicalField: string, mappings: CriteriaMappings): CriteriaFieldMapping { return ( mappings[logicalField] ?? { type: "root", column: logicalField, } ); } private assertSortableField(logicalField: string, params: ConvertParams): void { const sortableFields = params.sortableFields ?? ["id", "created_at"]; const strict = params.strictMode ?? false; if (sortableFields.includes(logicalField)) return; const msg = `[CriteriaToSequelizeConverter] Ignored ORDER BY '${logicalField}' because it is not sortable.`; if (strict) throw new Error(msg); console.warn(msg); } private buildWhereCondition( mapping: CriteriaFieldMapping, operator: symbol, value: unknown ): WhereOptions { if (operator === Op.is) { return this.buildNullWhereCondition(mapping, null); } if (operator === Op.not) { return this.buildNullWhereCondition(mapping, { [Op.ne]: null }); } switch (mapping.type) { case "root": return { [mapping.column]: { [operator]: value, }, }; case "association": return Sequelize.where(Sequelize.col(`${mapping.association}.${mapping.column}`), { [operator]: value, }) as unknown as WhereOptions; case "literal": return Sequelize.where(Sequelize.literal(mapping.expression), { [operator]: value, }) as unknown as WhereOptions; default: return this.assertNever(mapping); } } private buildOrderItem(mapping: CriteriaFieldMapping, direction: string): OrderItem { switch (mapping.type) { case "root": return [mapping.column, direction]; case "association": return [{ as: mapping.association }, mapping.column, direction] as OrderItem; case "literal": return [Sequelize.literal(mapping.expression), direction]; default: return this.assertNever(mapping); } } 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 "IS_NULL": return Op.is; case "IS_NOT_NULL": return Op.not; 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; default: return Op.eq; } } private transformValue(operator: symbol, value: unknown): unknown { if (operator === Op.is || operator === Op.not) return null; if (operator === Op.like || operator === Op.notLike) return `%${value}%`; if (value === "true") return true; if (value === "false") return false; return value; } private qualifyRootColumn(column: string, tableAlias?: string): string { if (!tableAlias) return this.quoteIdentifier(column); return `${this.quoteIdentifier(tableAlias)}.${this.quoteIdentifier(column)}`; } private quoteIdentifier(identifier: string): string { return `\`${identifier.replaceAll("`", "``")}\``; } private includeAttribute(options: FindOptions, expression: unknown, alias: string): void { if (!options.attributes) { options.attributes = { include: [] }; } if (Array.isArray(options.attributes)) { options.attributes.push([expression, alias] as never); return; } options.attributes.include = options.attributes.include ?? []; options.attributes.include.push([expression, alias] as never); } private assertNever(value: never): never { throw new Error(`[CriteriaToSequelizeConverter] Unsupported field mapping: ${String(value)}`); } private buildNullWhereCondition( mapping: CriteriaFieldMapping, value: unknown ): WhereOptions { switch (mapping.type) { case "root": return { [mapping.column]: value, }; case "association": return Sequelize.where(Sequelize.col(`${mapping.association}.${mapping.column}`), value) as unknown as WhereOptions; case "literal": return Sequelize.where(Sequelize.literal(mapping.expression), value) as unknown as WhereOptions; default: return this.assertNever(mapping); } } }