FactuGES_ERP/packages/rdx-criteria/src/criteria-to-sequelize-converter.ts

281 lines
8.3 KiB
TypeScript
Raw Normal View History

2025-11-11 18:57:04 +00:00
import { type FindOptions, Op, type OrderItem, Sequelize, type WhereOptions } from "sequelize";
2026-06-15 15:16:07 +00:00
import type { Criteria } from "./critera.js";
import type {
ConvertParams,
CriteriaFieldMapping,
CriteriaMappings,
ICriteriaToOrmConverter,
} from "./types.js";
2026-08-03 11:37:40 +00:00
import { buildBooleanFullTextQuery } from "./build-boolean-full-text-query.js";
2026-06-15 15:16:07 +00:00
import { appendOrder, normalizeOrder, prependOrder } from "./utils";
2025-05-26 10:38:45 +00:00
2025-10-24 11:01:51 +00:00
/**
2026-06-15 15:16:07 +00:00
* 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.
2025-10-24 11:01:51 +00:00
*/
2025-09-30 10:59:32 +00:00
export class CriteriaToSequelizeConverter implements ICriteriaToOrmConverter {
2026-06-15 15:16:07 +00:00
public convert(criteria: Criteria, params: ConvertParams = {}): FindOptions {
const options: FindOptions = {};
const mappings = params.mappings ?? {};
2025-05-26 10:38:45 +00:00
2025-09-30 10:59:32 +00:00
this.applyFilters(options, criteria, mappings);
this.applyQuickSearch(options, criteria, params);
2025-10-24 11:01:51 +00:00
this.applyOrder(options, criteria, mappings, params);
2025-09-30 10:59:32 +00:00
this.applyPagination(options, criteria);
2025-11-13 11:49:36 +00:00
return {
...options,
2026-06-15 15:16:07 +00:00
order: normalizeOrder(options.order),
2025-11-13 11:49:36 +00:00
};
2025-09-30 10:59:32 +00:00
}
2026-06-15 15:16:07 +00:00
public applyFilters(options: FindOptions, criteria: Criteria, mappings: CriteriaMappings): void {
2025-10-24 11:01:51 +00:00
if (!criteria.hasFilters()) return;
const filters: WhereOptions[] = [];
for (const filter of criteria.filters.value) {
2026-06-15 15:16:07 +00:00
const logicalField = filter.field.value;
const mapping = this.resolveMapping(logicalField, mappings);
2025-10-24 11:01:51 +00:00
const operator = this.mapOperator(filter.operator.value);
const value = this.transformValue(operator, filter.value.value);
2026-06-15 15:16:07 +00:00
filters.push(this.buildWhereCondition(mapping, operator, value));
2025-05-26 10:38:45 +00:00
}
2025-10-24 11:01:51 +00:00
options.where = options.where
? { [Op.and]: [options.where, ...filters] }
: { [Op.and]: filters };
2025-09-30 10:59:32 +00:00
}
2025-05-26 10:38:45 +00:00
2025-09-30 10:59:32 +00:00
public applyQuickSearch(options: FindOptions, criteria: Criteria, params: ConvertParams): void {
2026-06-15 15:16:07 +00:00
const { searchableFields = [], database, enableFullText = false, fullTextTableAlias } = params;
2025-09-30 10:59:32 +00:00
const term = typeof criteria.quickSearch === "string" ? criteria.quickSearch.trim() : "";
2025-10-24 11:01:51 +00:00
if (!term || searchableFields.length === 0 || !enableFullText) return;
2025-09-30 10:59:32 +00:00
2025-10-24 11:01:51 +00:00
if (!database) {
2026-06-15 15:16:07 +00:00
const msg = "[CriteriaToSequelizeConverter] enableFullText=true pero falta 'database'.";
2025-10-24 11:01:51 +00:00
if (params.strictMode) throw new Error(msg);
console.warn(msg);
2025-09-30 10:59:32 +00:00
return;
}
2026-08-03 11:37:40 +00:00
const booleanTerm = buildBooleanFullTextQuery(term);
if (!booleanTerm) return;
2025-09-30 10:59:32 +00:00
2026-06-15 15:16:07 +00:00
const qualifiedFields = searchableFields.map((field) =>
this.qualifyRootColumn(field, fullTextTableAlias)
2026-05-04 10:12:06 +00:00
);
const matchExpr = `MATCH(${qualifiedFields.join(", ")}) AGAINST (${database.escape(
2025-09-30 10:59:32 +00:00
booleanTerm
)} IN BOOLEAN MODE)`;
2026-05-04 10:12:06 +00:00
2025-09-30 10:59:32 +00:00
const matchLiteral = Sequelize.literal(matchExpr);
2026-06-15 15:16:07 +00:00
this.includeAttribute(options, matchLiteral, "score");
2025-09-30 10:59:32 +00:00
const scoreCondition = Sequelize.where(matchLiteral, { [Op.gt]: 0 });
2026-05-04 10:12:06 +00:00
2025-10-24 11:01:51 +00:00
options.where = options.where
? { [Op.and]: [options.where, scoreCondition] }
: { [Op.and]: [scoreCondition] };
2025-09-30 10:59:32 +00:00
prependOrder(options, [Sequelize.literal("score"), "DESC"]);
}
2025-10-24 11:01:51 +00:00
public applyOrder(
options: FindOptions,
criteria: Criteria,
mappings: CriteriaMappings,
params: ConvertParams
): void {
if (!criteria.hasOrder()) return;
2026-06-15 15:16:07 +00:00
const logicalField = criteria.order.orderBy.value;
2025-10-24 11:01:51 +00:00
const direction = criteria.order.orderType.value.toUpperCase();
2026-06-15 15:16:07 +00:00
this.assertSortableField(logicalField, params);
2025-10-24 11:01:51 +00:00
2026-06-15 15:16:07 +00:00
const mapping = this.resolveMapping(logicalField, mappings);
const orderItem = this.buildOrderItem(mapping, direction);
2025-10-24 11:01:51 +00:00
2026-06-15 15:16:07 +00:00
appendOrder(options, orderItem);
2025-09-30 10:59:32 +00:00
}
2025-05-26 10:38:45 +00:00
2025-09-30 10:59:32 +00:00
public applyPagination(options: FindOptions, criteria: Criteria): void {
2026-06-15 15:16:07 +00:00
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,
2025-10-24 11:01:51 +00:00
}
2026-06-15 15:16:07 +00:00
);
}
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 });
}
2026-06-15 15:16:07 +00:00
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);
2025-05-26 10:38:45 +00:00
}
}
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;
2025-05-26 10:38:45 +00:00
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;
}
}
2025-11-11 18:57:04 +00:00
private transformValue(operator: symbol, value: unknown): unknown {
if (operator === Op.is || operator === Op.not) return null;
2025-10-24 11:01:51 +00:00
if (operator === Op.like || operator === Op.notLike) return `%${value}%`;
2026-06-15 15:16:07 +00:00
if (value === "true") return true;
if (value === "false") return false;
2026-05-26 15:03:05 +00:00
2025-05-26 10:38:45 +00:00
return value;
}
2026-05-04 10:12:06 +00:00
2026-06-15 15:16:07 +00:00
private qualifyRootColumn(column: string, tableAlias?: string): string {
if (!tableAlias) return this.quoteIdentifier(column);
2026-05-04 10:12:06 +00:00
2026-06-15 15:16:07 +00:00
return `${this.quoteIdentifier(tableAlias)}.${this.quoteIdentifier(column)}`;
2026-05-04 10:12:06 +00:00
}
2026-06-15 15:16:07 +00:00
private quoteIdentifier(identifier: string): string {
return `\`${identifier.replaceAll("`", "``")}\``;
}
2026-05-26 15:03:05 +00:00
2026-06-15 15:16:07 +00:00
private includeAttribute(options: FindOptions, expression: unknown, alias: string): void {
if (!options.attributes) {
options.attributes = { include: [] };
}
2026-06-15 15:16:07 +00:00
if (Array.isArray(options.attributes)) {
options.attributes.push([expression, alias] as never);
return;
}
2026-06-15 15:16:07 +00:00
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);
}
}
2025-05-26 10:38:45 +00:00
}