@@ -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 ) ;