283 lines
8.3 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|