diff --git a/package.json b/package.json index 7fcc6b1..97f1495 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@windychen/trans-form-typeorm", - "version": "0.1.1", + "version": "0.1.2", "description": "TypeORM adapter for @windychen/trans-form-middleware: structured filters to FindOptionsWhere / QueryBuilder", "type": "module", "exports": { @@ -11,7 +11,8 @@ ], "scripts": { "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "e2e": "node scripts/e2e-sql.mjs" }, "keywords": [ "typeorm", diff --git a/scripts/e2e-sql.mjs b/scripts/e2e-sql.mjs new file mode 100644 index 0000000..426c3db --- /dev/null +++ b/scripts/e2e-sql.mjs @@ -0,0 +1,240 @@ +#!/usr/bin/env node +/** + * trans-form 三件套 SQL 端到端验证(真实链路) + * + * 链路: 组件输出 filters → middleware parseFilters/toTree → typeorm 适配器 + * (toFindOptionsWhere / applyToQueryBuilder) → 真实 SQLite 查询 → 断言结果 + * + * 运行: node scripts/e2e-sql.mjs (或 npm run e2e) + */ +import { DataSource, EntitySchema } from 'typeorm'; +import { parseFilters, toTree } from '../../trans-form-middleware/src/index.js'; +import { toFindOptionsWhere, applyToQueryBuilder } from '../src/index.js'; + +let passed = 0; +let failed = 0; + +function check(name, cond, detail = '') { + if (cond) { + passed++; + console.log(` ✅ ${name}${detail ? ` — ${detail}` : ''}`); + } else { + failed++; + console.log(` ❌ ${name}${detail ? ` — ${detail}` : ''}`); + } +} + +// ---------- 1. 真实数据库 ---------- +const Employee = new EntitySchema({ + name: 'Employee', + tableName: 'employees', + columns: { + id: { type: 'int', primary: true, generated: true }, + name: { type: 'varchar', nullable: true }, + department: { type: 'varchar' }, + salary: { type: 'real' }, + age: { type: 'int' }, + hired_at: { type: 'varchar' }, + active: { type: 'int' }, + tags: { type: 'varchar' }, + }, +}); + +const seed = [ + { name: '陈乐强', department: '研发部', salary: 25000, age: 32, hired_at: '2021-03-15', active: 1, tags: 'vue,node,sqlite' }, + { name: '李婷', department: '设计部', salary: 18000, age: 28, hired_at: '2022-07-01', active: 1, tags: 'ui,figma' }, + { name: '王强', department: '研发部', salary: 22000, age: 35, hired_at: '2020-01-10', active: 1, tags: 'go,redis' }, + { name: '赵敏', department: '市场部', salary: 15000, age: 26, hired_at: '2023-05-20', active: 1, tags: 'seo,ads' }, + { name: '孙磊', department: '研发部', salary: 30000, age: 40, hired_at: '2018-11-05', active: 0, tags: 'java,k8s' }, + { name: '周芳', department: '财务部', salary: 17000, age: 30, hired_at: '2022-02-14', active: 1, tags: 'excel,erp' }, + { name: '吴刚', department: '运维部', salary: 21000, age: 33, hired_at: '2019-08-30', active: 1, tags: 'linux,docker' }, + { name: '郑爽', department: '设计部', salary: 16500, age: 27, hired_at: '2023-09-01', active: 1, tags: 'ui,ps' }, + { name: '冯涛', department: '市场部', salary: 14500, age: 25, hired_at: '2024-01-15', active: 1, tags: 'ads' }, + { name: '韩雪', department: '财务部', salary: 19000, age: 34, hired_at: '2020-06-22', active: 0, tags: 'erp,audit' }, +]; + +const allowedFields = ['name', 'department', 'salary', 'age', 'hired_at', 'active', 'tags']; + +const ds = new DataSource({ + type: 'better-sqlite3', + database: ':memory:', + entities: [Employee], + synchronize: true, +}); +await ds.initialize(); +const repo = ds.getRepository('Employee'); +await repo.save(seed); +console.log(`✅ 数据库就绪: employees ${seed.length} 条\n`); + +// ---------- 工具:两种查询方式 ---------- +async function queryFind(parsed) { + const where = toFindOptionsWhere(parsed); + const rows = await repo.find({ where }); + return rows; +} + +async function queryQb(parsed, { alias = 'u' } = {}) { + const qb = repo.createQueryBuilder(alias); + const { parameters } = applyToQueryBuilder(qb, parsed, { alias }); + const sql = qb.getSql(); + const rows = await qb.getMany(); + return { rows, sql, parameters }; +} + +// ---------- 场景 1:纯 AND 组合(EQ + GTE + LIKE) ---------- +console.log('═══ 场景 1: AND 组合 部门=研发部 AND 薪资>=20000 AND tags LIKE sqlite ═══'); +{ + const filters = [ + { field: 'department', value: '研发部', query: 'EQ' }, + { field: 'salary', value: 20000, query: 'GTE', logic: 'AND' }, + { field: 'tags', value: 'sqlite', query: 'LIKE', logic: 'AND' }, + ]; + const parsed = parseFilters(filters, { allowedFields }); + + const viaFind = await queryFind(parsed); + const viaQb = await queryQb(parsed); + + console.log(` 中间件输出: ${JSON.stringify(parsed.map(({ op, ...r }) => r))}`); + console.log(` QueryBuilder SQL: ${viaQb.sql}`); + + check('FindOptionsWhere 命中 1 条 (陈乐强)', viaFind.length === 1 && viaFind[0].name === '陈乐强', `${viaFind.length} 条`); + check('QueryBuilder 命中 1 条 (陈乐强)', viaQb.rows.length === 1 && viaQb.rows[0].name === '陈乐强', `${viaQb.rows.length} 条`); + check('SQL 含 LIKE 且参数化', viaQb.sql.includes('LIKE') && viaQb.parameters.p3 === '%sqlite%', JSON.stringify(viaQb.parameters)); +} + +// ---------- 场景 2:OR 同层 + IN/BETWEEN ---------- +console.log('\n═══ 场景 2: (部门 IN 研发部,财务部) OR (年龄 BETWEEN 25-35) ═══'); +{ + const filters = [ + { field: 'department', value: '研发部,财务部', query: 'IN', values: ['研发部', '财务部'] }, + { field: 'age', value: '25,35', query: 'BETWEEN', values: ['25', '35'], logic: 'OR' }, + ]; + const parsed = parseFilters(filters, { allowedFields }); + + const viaFind = await queryFind(parsed); + const viaQb = await queryQb(parsed); + + // 手算: 部门 IN 研发部(陈乐强,王强,孙磊)+财务部(周芳,韩雪) = 5 人 + // ∪ 年龄 25-35 (李婷,赵敏,周芳,吴刚,郑爽,冯涛,韩雪,陈乐强,王强) = 9 人 + // 交集(陈乐强,王强,周芳,韩雪) = 4 → 并集 = 5 + 9 - 4 = 10 + const expected = 10; + console.log(` QueryBuilder SQL: ${viaQb.sql}`); + check(`FindOptionsWhere 命中 ${expected} 条`, viaFind.length === expected, `${viaFind.length} 条`); + check(`QueryBuilder 命中 ${expected} 条`, viaQb.rows.length === expected, `${viaQb.rows.length} 条`); + // better-sqlite3 驱动把 (:...p1) 展开渲染为 (?, ?) + check('SQL 含 IN 展开参数', /IN \(\?, \?\)/.test(viaQb.sql), viaQb.sql.match(/IN \([^)]*\)/)?.[0] ?? ''); +} + +// ---------- 场景 3:深层 OR 嵌套(toTree → 适配器) ---------- +console.log('\n═══ 场景 3: 深层嵌套 (部门=研发部) AND (年龄<30 OR 薪资>28000) ═══'); +{ + const filters = [ + { field: 'department', value: '研发部', query: 'EQ' }, + { field: 'age', value: 30, query: 'LT', logic: 'AND' }, + { field: 'salary', value: 28000, query: 'GT', logic: 'OR' }, + ]; + const parsed = parseFilters(filters, { allowedFields }); + const tree = toTree(parsed); + + const viaFind = await queryFind(tree); + const viaQb = await queryQb(tree); + + // 研发部: 陈乐强(32,25000) 王强(35,22000) 孙磊(40,30000) → (age<30 OR salary>28000): 孙磊(40,30000 薪资>28000) + const expected = ['孙磊']; + console.log(` toTree: ${JSON.stringify(tree.map(({ op, ...r }) => r))}`); + console.log(` QueryBuilder SQL: ${viaQb.sql}`); + check('FindOptionsWhere 分配律命中 孙磊', viaFind.length === 1 && viaFind[0].name === '孙磊', `${viaFind.map((r) => r.name).join(',')}`); + check('QueryBuilder 括号分组命中 孙磊', viaQb.rows.length === 1 && viaQb.rows[0].name === '孙磊', `${viaQb.rows.map((r) => r.name).join(',')}`); + check('SQL 含括号表达式', viaQb.sql.includes('(') && viaQb.sql.includes(')'), viaQb.sql); +} + +// ---------- 场景 4:状态标记(disabled/hidden 跳过, readOnly 保留, skipEmpty) ---------- +console.log('\n═══ 场景 4: 状态标记 disabled/hidden/readOnly/empty ═══'); +{ + const filters = [ + { field: 'department', value: '设计部', query: 'EQ' }, + { field: 'salary', value: 99999, query: 'EQ', logic: 'AND', disabled: true }, // 应跳过 + { field: 'age', value: 999, query: 'EQ', logic: 'AND', hidden: true }, // 应跳过 + { field: 'active', value: 1, query: 'EQ', logic: 'AND', readOnly: true }, // 应保留 + { field: 'name', value: '', query: 'EQ', logic: 'AND', empty: true }, // 默认保留 → 无值跳过 + ]; + const parsed = parseFilters(filters, { allowedFields }); + const viaFind = await queryFind(parsed); + const viaQb = await queryQb(parsed); + + // 设计部 + active=1: 李婷, 郑爽 + console.log(` 中间件输出: ${JSON.stringify(parsed.map(({ op, ...r }) => r))}`); + check('disabled/hidden 被跳过, readOnly 保留', parsed.length === 3 && parsed.every((p) => !p.disabled && !p.hidden), `${parsed.length} 项`); + check('FindOptionsWhere 命中设计部在职 2 人', viaFind.length === 2, `${viaFind.map((r) => r.name).join(',')}`); + check('QueryBuilder 命中 2 人', viaQb.rows.length === 2, `${viaQb.rows.map((r) => r.name).join(',')}`); + + // skipEmpty=true 时 empty 项被剔除 + const parsed2 = parseFilters(filters, { allowedFields, skipEmpty: true }); + check('skipEmpty=true 剔除 empty 项', parsed2.length === 2, `${parsed2.length} 项`); +} + +// ---------- 场景 5:IS_NULL / IS_NOT_NULL ---------- +console.log('\n═══ 场景 5: IS_NULL / IS_NOT_NULL ═══'); +{ + // 额外造一条 name 为 NULL 的记录 + await repo.save({ name: null, department: '临时部', salary: 0, age: 0, hired_at: '2024-01-01', active: 1, tags: '' }); + + const filtersNull = [{ field: 'name', value: null, query: 'IS_NULL' }]; + const parsedNull = parseFilters(filtersNull, { allowedFields }); + const viaFindNull = await queryFind(parsedNull); + const viaQbNull = await queryQb(parsedNull); + console.log(` IS_NULL SQL: ${viaQbNull.sql}`); + check('IS_NULL 命中 1 条 (name 为空)', viaFindNull.length === 1, `${viaFindNull.length} 条`); + check('IS_NULL QueryBuilder 命中 1 条', viaQbNull.rows.length === 1, `${viaQbNull.rows.length} 条`); + + const filtersNotNull = [{ field: 'name', value: null, query: 'IS_NOT_NULL' }]; + const parsedNotNull = parseFilters(filtersNotNull, { allowedFields }); + const viaQbNotNull = await queryQb(parsedNotNull); + console.log(` IS_NOT_NULL SQL: ${viaQbNotNull.sql}`); + check('IS_NOT_NULL 命中 10 条', viaQbNotNull.rows.length === 10, `${viaQbNotNull.rows.length} 条`); +} + +// ---------- 场景 6:两路查询结果等价性(复杂 AND/OR 混合,经 toTree 转树) ---------- +console.log('\n═══ 场景 6: 两路查询结果等价性(A OR B AND C,toTree 后左结合为 (A OR B) AND C) ═══'); +{ + const filters = [ + { field: 'department', value: '研发部', query: 'EQ' }, + { field: 'age', value: 30, query: 'GTE', logic: 'OR' }, + { field: 'active', value: 1, query: 'EQ', logic: 'AND' }, + ]; + const parsed = parseFilters(filters, { allowedFields }); + const tree = toTree(parsed); + // toTree 左结合: (department=研发部 OR age>=30) AND active=1 + const viaFind = await queryFind(tree); + const viaQb = await queryQb(tree); + + const findNames = viaFind.map((r) => r.name).sort(); + const qbNames = viaQb.rows.map((r) => r.name).sort(); + console.log(` toTree: ${JSON.stringify(tree.map(({ op, ...r }) => r))}`); + console.log(` QueryBuilder SQL: ${viaQb.sql}`); + check('FindOptionsWhere 与 QueryBuilder 结果一致', JSON.stringify(findNames) === JSON.stringify(qbNames), `${findNames.length} 条: ${findNames.join(',')}`); + // (研发部 OR age>=30) AND active=1: 研发部(陈乐强,王强,孙磊) ∪ age>=30(陈,王,孙,周芳,吴刚,韩雪) ∩ active=1 → 陈乐强,王强,周芳,吴刚 + const expected6 = ['吴刚', '周芳', '王强', '陈乐强']; + check('结果符合左结合语义', JSON.stringify(findNames) === JSON.stringify(expected6), `${findNames.join(',')}`); +} + +// ---------- 场景 7:扁平列表混合逻辑 → QueryBuilder 的 SQL 优先级注意 ---------- +console.log('\n═══ 场景 7: 扁平混合逻辑 A OR B AND C 直接喂 QueryBuilder(SQL 原生优先级: AND 先于 OR) ═══'); +{ + const filters = [ + { field: 'department', value: '研发部', query: 'EQ' }, + { field: 'age', value: 30, query: 'GTE', logic: 'OR' }, + { field: 'active', value: 1, query: 'EQ', logic: 'AND' }, + ]; + const parsed = parseFilters(filters, { allowedFields }); + const viaQb = await queryQb(parsed); + console.log(` QueryBuilder SQL: ${viaQb.sql}`); + // SQL: department=? OR (age>=? AND active=?) → 研发部 3 人 ∪ (age>=30 ∩ active=1: 陈乐强,王强,周芳,吴刚) = 5 人 + console.log(` 结果 ${viaQb.rows.length} 条(与 toTree 转树后的 4 条不同——混合逻辑请先 toTree 再入 QueryBuilder)`); + check('结果按 SQL 优先级为 5 条', viaQb.rows.length === 5, `${viaQb.rows.map((r) => r.name).join(',')}`); +} + +await ds.destroy(); +console.log(`\n${'═'.repeat(48)}`); +console.log(failed === 0 ? `🎉 端到端验证全部通过 (${passed} 项断言)` : `⚠️ ${failed} 项断言失败 (通过 ${passed} 项)`); +process.exit(failed === 0 ? 0 : 1); diff --git a/src/applyToQueryBuilder.js b/src/applyToQueryBuilder.js index b629934..1eae138 100644 --- a/src/applyToQueryBuilder.js +++ b/src/applyToQueryBuilder.js @@ -163,7 +163,8 @@ export function applyToQueryBuilder(qb, filters, options = {}) { if (skipFlags.some((flag) => item[flag] === true)) return null; if (skipEmpty && item.empty === true) return null; - // Nested group node: { logic, children: [...] } + // Nested group node: { logic, children: [...] } — skip empty-value check + // for the group wrapper itself (it has no value; only its leaves do). if (Array.isArray(item.children)) { const childExprs = []; const childParams = {}; @@ -172,7 +173,10 @@ export function applyToQueryBuilder(qb, filters, options = {}) { if (!child || typeof child !== 'object') continue; if (skipFlags.some((flag) => child[flag] === true)) continue; if (skipEmpty && child.empty === true) continue; - if (shouldSkipEmptyValue(child)) continue; + // Nested group child ({logic, children}) has no scalar value — the + // empty-value check applies to leaves only, and buildClause handles it + // internally for leaves. + if (!Array.isArray(child.children) && shouldSkipEmptyValue(child)) continue; const built = buildClause(child); if (!built) continue; diff --git a/src/operatorMap.js b/src/operatorMap.js index 9b0c0ec..6c97cca 100644 --- a/src/operatorMap.js +++ b/src/operatorMap.js @@ -11,6 +11,18 @@ import { IsNull, } from 'typeorm'; +/** + * Normalize a LIKE value: wrap with % unless it already contains %. + * Keeps both adapter entry points consistent (README: "LIKE 值自动加 %"). + * @param {*} value + * @returns {string} + */ +export function likeValue(value) { + const s = String(value ?? ''); + if (s.includes('%')) return s; + return `%${s}%`; +} + /** * Default query-code → FindOperator factory map. * @type {Record import('typeorm').FindOperator<*>>} @@ -22,7 +34,7 @@ export const DEFAULT_OPERATOR_MAP = { GTE: (value) => MoreThanOrEqual(value), LT: (value) => LessThan(value), LTE: (value) => LessThanOrEqual(value), - LIKE: (value) => Like(value), + LIKE: (value) => Like(likeValue(value)), IN: (_value, values) => In(values), NOT_IN: (_value, values) => Not(In(values)), BETWEEN: (_value, values) => Between(values[0], values[1]), diff --git a/test/transFormTypeorm.test.js b/test/transFormTypeorm.test.js index a37e893..1974f9c 100644 --- a/test/transFormTypeorm.test.js +++ b/test/transFormTypeorm.test.js @@ -139,6 +139,14 @@ describe('toFindOptionsWhere', () => { expect(out[0].b.type).toBe('lessThanOrEqual'); expect(out[0].c.type).toBe('like'); }); + + it('LIKE 自动加 %(与 QueryBuilder 一致)', () => { + const out = toFindOptionsWhere([{ field: 'c', query: 'LIKE', value: 'sqlite' }]); + expect(out[0].c.value).toBe('%sqlite%'); + // 已有 % 不再重复加 + const out2 = toFindOptionsWhere([{ field: 'c', query: 'LIKE', value: 'sqlite%' }]); + expect(out2[0].c.value).toBe('sqlite%'); + }); }); describe('toFindOptionsWhere 深层 OR 嵌套', () => { @@ -331,6 +339,33 @@ describe('applyToQueryBuilder', () => { expect(groupExpr).toContain('c = :p3'); }); + it('嵌套组作为 children 时不被空值检查误跳(回归: 2026-08-07 e2e 抓出)', () => { + // toTree 输出形态: 顶层 {logic, children}, 内层还有 {logic, children} + const tree = [ + { + logic: 'OR', + children: [ + { + logic: 'AND', + children: [ + { field: 'a', query: 'EQ', value: 1 }, + { field: 'b', query: 'EQ', value: 2, logic: 'AND' }, + ], + }, + { field: 'c', query: 'EQ', value: 3, logic: 'OR' }, + ], + }, + ]; + const qb = createMockQb(); + applyToQueryBuilder(qb, tree); + // 内层 AND 组必须被保留(曾因 shouldSkipEmptyValue 误判整组丢失,只剩 c) + const expr = qb.andWhere.mock.calls[0][0]; + expect(expr).toContain('a = :p1'); + expect(expr).toContain('b = :p2'); + expect(expr).toContain('OR'); + expect(expr).toContain('c = :p3'); + }); + it('参数递增 p1/p2', () => { const qb = createMockQb(); const { parameters } = applyToQueryBuilder(qb, [