This commit is contained in:
+3
-2
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
@@ -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
@@ -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]),
|
||||
|
||||
@@ -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, [
|
||||
|
||||
Reference in New Issue
Block a user