chore: 版本 0.1.2 更新 修复嵌套组误跳与 LIKE 缺 %,新增 e2e 脚本
Node.js Build / build (push) Successful in 6s

This commit is contained in:
2026-08-07 16:25:34 +08:00
parent 0ae29e9c09
commit a39e5e1b8e
5 changed files with 297 additions and 5 deletions
+3 -2
View File
@@ -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",
+240
View File
@@ -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);
+6 -2
View File
@@ -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;
+13 -1
View File
@@ -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<string, (value?: *, values?: *[]) => 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]),
+35
View File
@@ -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, [