171 lines
5.4 KiB
TypeScript
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;
|
|
}
|
|
}
|