FactuGES_ERP/packages/rdx-criteria/src/criteria-to-sequelize-converter.ts
2026-08-03 17:42:23 +02:00

283 lines
8.3 KiB
TypeScript

import { type FindOptions, Op, type OrderItem, Sequelize, type WhereOptions } from "sequelize";
import { buildBooleanFullTextQuery } from "./build-boolean-full-text-query";
import type { Criteria } from "./critera.js";
import type {
ConvertParams,
CriteriaFieldMapping,
CriteriaMappings,
ICriteriaToOrmConverter,
} from "./types.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);
}
}
}