diff --git a/packages/rdx-criteria/src/__tests__/build-boolean-full-text-query.test.ts b/packages/rdx-criteria/src/__tests__/build-boolean-full-text-query.test.ts new file mode 100644 index 00000000..be808bd8 --- /dev/null +++ b/packages/rdx-criteria/src/__tests__/build-boolean-full-text-query.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "vitest"; + +import { buildBooleanFullTextQuery } from "../build-boolean-full-text-query"; + +describe("buildBooleanFullTextQuery", () => { + test.each([ + ["pr", "+pr*"], + ["pr-", "+pr*"], + ["-pr", "+pr*"], + ["cliente prueba", "+cliente* +prueba*"], + ["cliente-prueba", "+cliente* +prueba*"], + ["José Pérez", "+José* +Pérez*"], + ["++ -- **", ""], + ["", ""], + [" ", ""], + ['+ - > < ( ) ~ * " @', ""], + ])("normalizes %p into %p", (input, expected) => { + expect(buildBooleanFullTextQuery(input)).toBe(expected); + }); +}); diff --git a/packages/rdx-criteria/src/__tests__/criteria-to-sequelize-converter.test.ts b/packages/rdx-criteria/src/__tests__/criteria-to-sequelize-converter.test.ts new file mode 100644 index 00000000..536a848d --- /dev/null +++ b/packages/rdx-criteria/src/__tests__/criteria-to-sequelize-converter.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "vitest"; +import { Op, type Sequelize } from "sequelize"; + +import { Criteria } from "../critera"; +import { CriteriaToSequelizeConverter } from "../criteria-to-sequelize-converter"; + +const createDatabaseStub = () => + ({ + escape: (value: string) => `'${value.replaceAll("'", "''")}'`, + }) as Sequelize; + +describe("CriteriaToSequelizeConverter quick search", () => { + test("builds a full-text filter and score for free text search", () => { + const converter = new CriteriaToSequelizeConverter(); + const criteria = Criteria.fromPrimitives([], "name", "asc", 10, 0, "pr-"); + + const options = converter.convert(criteria, { + searchableFields: ["name", "trade_name"], + sortableFields: ["name"], + enableFullText: true, + database: createDatabaseStub(), + strictMode: true, + }); + + expect(options.attributes).toEqual({ + include: [[expect.objectContaining({ val: expect.stringContaining("'+pr*'") }), "score"]], + }); + expect(options.order).toEqual([ + [expect.objectContaining({ val: "score" }), "DESC"], + ["name", "ASC"], + ]); + expect(options.where).toEqual({ + [Op.and]: expect.any(Array), + }); + }); + + test("omits MATCH, score and score ordering when the normalized search is empty", () => { + const converter = new CriteriaToSequelizeConverter(); + const criteria = Criteria.fromPrimitives([], "name", "asc", 10, 0, "++ -- **"); + + const options = converter.convert(criteria, { + searchableFields: ["name", "trade_name"], + sortableFields: ["name"], + enableFullText: true, + database: createDatabaseStub(), + strictMode: true, + }); + + expect(options.attributes).toBeUndefined(); + expect(options.order).toEqual([["name", "ASC"]]); + expect(options.where).toBeUndefined(); + }); +}); diff --git a/packages/rdx-criteria/src/build-boolean-full-text-query.ts b/packages/rdx-criteria/src/build-boolean-full-text-query.ts new file mode 100644 index 00000000..921f9ffc --- /dev/null +++ b/packages/rdx-criteria/src/build-boolean-full-text-query.ts @@ -0,0 +1,9 @@ +const FULL_TEXT_TOKEN_PATTERN = /[\p{L}\p{N}]+/gu; + +export function buildBooleanFullTextQuery(search: string): string { + if (!search) return ""; + + const terms = search.normalize("NFKC").match(FULL_TEXT_TOKEN_PATTERN) ?? []; + + return terms.map((term) => `+${term}*`).join(" "); +} diff --git a/packages/rdx-criteria/src/criteria-to-sequelize-converter.ts b/packages/rdx-criteria/src/criteria-to-sequelize-converter.ts index f5ebf263..47b2d62d 100644 --- a/packages/rdx-criteria/src/criteria-to-sequelize-converter.ts +++ b/packages/rdx-criteria/src/criteria-to-sequelize-converter.ts @@ -7,6 +7,7 @@ import type { CriteriaMappings, ICriteriaToOrmConverter, } from "./types.js"; +import { buildBooleanFullTextQuery } from "./build-boolean-full-text-query.js"; import { appendOrder, normalizeOrder, prependOrder } from "./utils"; /** @@ -66,11 +67,8 @@ export class CriteriaToSequelizeConverter implements ICriteriaToOrmConverter { return; } - const booleanTerm = term - .split(/\s+/) - .filter(Boolean) - .map((word) => `+${word}*`) - .join(" "); + const booleanTerm = buildBooleanFullTextQuery(term); + if (!booleanTerm) return; const qualifiedFields = searchableFields.map((field) => this.qualifyRootColumn(field, fullTextTableAlias) @@ -216,7 +214,6 @@ export class CriteriaToSequelizeConverter implements ICriteriaToOrmConverter { return Op.lt; case "LOWER_THAN_OR_EQUAL": return Op.lte; - case "EQUALS": default: return Op.eq; } diff --git a/packages/rdx-criteria/src/index.ts b/packages/rdx-criteria/src/index.ts index 090ea255..6f259500 100644 --- a/packages/rdx-criteria/src/index.ts +++ b/packages/rdx-criteria/src/index.ts @@ -1,4 +1,5 @@ export * from "./critera"; +export * from "./build-boolean-full-text-query"; export * from "./criteria-from-url-converter"; export * from "./criteria-to-sequelize-converter"; export * from "./defaults";