Uecko_ERP/packages/rdx-criteria/src/criteria-to-sequelize-converter.ts
2025-11-13 12:49:36 +01:00

171 lines
5.4 KiB
TypeScript

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;
}
}