This commit is contained in:
@@ -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);
|
||||
Reference in New Issue
Block a user