4 Commits

Author SHA1 Message Date
windychen a39e5e1b8e chore: 版本 0.1.2 更新 修复嵌套组误跳与 LIKE 缺 %,新增 e2e 脚本
Node.js Build / build (push) Successful in 6s
2026-08-07 16:25:34 +08:00
windychen 0ae29e9c09 chore: 版本 0.1.1 更新 README 版本说明(数据库修复后重触发)
Node.js Build / build (push) Successful in 5s
2026-08-07 16:11:24 +08:00
windychen 31ee95aaa8 chore: 版本 0.1.1 更新 README 版本说明 2026-08-07 16:06:24 +08:00
windychen bafa6198e5 chore: 版本 0.1.1 更新 toFindOptionsWhere 支持深层 OR 嵌套分配律展开
Node.js Build / build (push) Successful in 6s
2026-08-07 11:36:07 +08:00
9 changed files with 967 additions and 72 deletions
+2 -2
View File
@@ -15,7 +15,7 @@
| 3 | ESM only,不打包(`exports: "./src/index.js"`) |
| 4 | 两种输出:`FindOptionsWhere[]`(对象式)+ QueryBuilder 条件(表达式式) |
| 5 | AND 项合并同一对象;OR 项拆数组元素(TypeORM 语义) |
| 6 | 嵌套分组(Q6)用括号表达式递归生成 |
| 6 | **深层 OR 嵌套(v0.1.1)**:`toFindOptionsWhere` 接受 `toTree` 树输入,递归 + 分配律展开(`X AND (A OR B)` → `[{X,A},{X,B}]`),同字段 AND 用 `And()` 组合 |
| 7 | 参数化绑定(`:p1 :p2`),杜绝字符串拼接注入 |
| 8 | 空 value 且非 NULL 查询自动跳过;LIKE 自动补 `%` |
| 9 | `disabled`/`hidden` 默认跳过(对齐 Q60/Q61),`skipEmpty` 可配 |
@@ -40,5 +40,5 @@ EQ NE GT GTE LT LTE LIKE IN NOT_IN BETWEEN IS_NULL IS_NOT_NULL
## 未决项 / 后续
- TypeORM `FindOptionsWhere` 对深层 OR 嵌套(如 `A OR (B AND C)` 内嵌)表达能力有限,深层场景建议走 `applyToQueryBuilder`
- 可选:`trans-form-knex`(决策 #24 提及的另一适配方向)
- 分配律展开的最坏情况指数增长:极端复杂布尔树走 `applyToQueryBuilder`(括号表达式,无展开成本)
+16 -2
View File
@@ -2,7 +2,7 @@
TypeORM 适配包:把 `@windychen/trans-form-middleware` 解析出的结构化过滤器转为 **FindOptionsWhere**(对象式查询)或 **QueryBuilder** 条件(支持嵌套分组)。
> 版本 0.1.0 — 首个可发布版本:`toFindOptionsWhere` / `applyToQueryBuilder`,覆盖 EQ/NE/GT/GTE/LT/LTE/LIKE/IN/NOT_IN/BETWEEN/IS_NULL/IS_NOT_NULL 全部操作符。
> 版本 0.1.1 — `toFindOptionsWhere` 支持深层 OR 嵌套(递归 + 分配律展开);0.1.0 为首个可发布版本:`toFindOptionsWhere` / `applyToQueryBuilder`,覆盖 EQ/NE/GT/GTE/LT/LTE/LIKE/IN/NOT_IN/BETWEEN/IS_NULL/IS_NOT_NULL 全部操作符。
## 安装
@@ -40,11 +40,25 @@ const { qb, parameters } = applyToQueryBuilder(repo.createQueryBuilder('u'), fil
| 函数 | 说明 |
|------|------|
| `toFindOptionsWhere(filters, options?)` | → `FindOptionsWhere[]`。AND 项合并进同一对象,OR 项拆成数组元素(`where: [{a:1},{b:2}]` = a=1 OR b=2) |
| `toFindOptionsWhere(filters, options?)` | → `FindOptionsWhere[]`。支持**深层 OR 嵌套**(递归 + 分配律展开):AND 项合并进同一对象,OR 项拆成数组元素,`X AND (A OR B)` 自动展开为 `[{X,A},{X,B}]` |
| `applyToQueryBuilder(qb, filters, options?)` | → `{ qb, parameters }`。支持嵌套分组括号表达式、参数绑定、alias 前缀 |
| `mapOperator(query, operatorMap?)` | 操作符码 → 工厂函数 |
| `DEFAULT_OPERATOR_MAP` | 默认操作符映射表 |
### 深层 OR 嵌套(toFindOptionsWhere)
输入支持 `toTree` 输出的嵌套结构(`{ logic, children }`),自动展开:
| 逻辑 | 输出 |
|------|------|
| `A OR B` | `[{A}, {B}]` |
| `X AND (A OR B)` | `[{X,A}, {X,B}]`(分配律) |
| `(A OR B) AND (C OR D)` | `[{A,C},{A,D},{B,C},{B,D}]`(笛卡尔积) |
| `A OR (B AND C)` | `[{A}, {B,C}]` |
| 同字段 AND(`name='a' AND name LIKE 'b'`) | `And(Equal('a'), Like('b'))` |
> 注意:分配律展开在最坏情况下呈指数增长(每组 OR 分支相乘)。实际查询条件数量有限,可放心使用;极端复杂的布尔树建议走 `applyToQueryBuilder`(括号表达式,无展开成本)。
### 选项
**toFindOptionsWhere**:`{ skipFlags = ['disabled','hidden'], skipEmpty = false, operatorMap }`
+388 -4
View File
@@ -8,6 +8,9 @@
"name": "@windychen/trans-form-typeorm",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"better-sqlite3": "^12.11.1"
},
"devDependencies": {
"typeorm": "^0.3.20",
"vitest": "^3.2.4"
@@ -1091,7 +1094,6 @@
"version": "1.5.1",
"resolved": "http://verdaccio:4873/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"dev": true,
"funding": [
{
"type": "github",
@@ -1108,6 +1110,64 @@
],
"license": "MIT"
},
"node_modules/better-sqlite3": {
"version": "12.11.1",
"resolved": "http://verdaccio:4873/better-sqlite3/-/better-sqlite3-12.11.1.tgz",
"integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "http://verdaccio:4873/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "http://verdaccio:4873/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/bl/node_modules/buffer": {
"version": "5.7.1",
"resolved": "http://verdaccio:4873/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/brace-expansion": {
"version": "2.1.4",
"resolved": "http://verdaccio:4873/brace-expansion/-/brace-expansion-2.1.4.tgz",
@@ -1230,6 +1290,12 @@
"node": ">= 16"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "http://verdaccio:4873/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "http://verdaccio:4873/cliui/-/cliui-8.0.1.tgz",
@@ -1384,6 +1450,21 @@
}
}
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "http://verdaccio:4873/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/dedent": {
"version": "1.7.2",
"resolved": "http://verdaccio:4873/dedent/-/dedent-1.7.2.tgz",
@@ -1409,6 +1490,15 @@
"node": ">=6"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "http://verdaccio:4873/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "http://verdaccio:4873/define-data-property/-/define-data-property-1.1.4.tgz",
@@ -1427,6 +1517,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "http://verdaccio:4873/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "http://verdaccio:4873/dotenv/-/dotenv-16.6.1.tgz",
@@ -1469,6 +1568,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "http://verdaccio:4873/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "http://verdaccio:4873/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -1571,6 +1679,15 @@
"@types/estree": "^1.0.0"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "http://verdaccio:4873/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "http://verdaccio:4873/expect-type/-/expect-type-1.4.0.tgz",
@@ -1599,6 +1716,12 @@
}
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "http://verdaccio:4873/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "http://verdaccio:4873/for-each/-/for-each-0.3.5.tgz",
@@ -1632,6 +1755,12 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "http://verdaccio:4873/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "http://verdaccio:4873/fsevents/-/fsevents-2.3.3.tgz",
@@ -1706,6 +1835,12 @@
"node": ">= 0.4"
}
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "http://verdaccio:4873/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/glob": {
"version": "10.5.0",
"resolved": "http://verdaccio:4873/glob/-/glob-10.5.0.tgz",
@@ -1800,7 +1935,6 @@
"version": "1.2.1",
"resolved": "http://verdaccio:4873/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"dev": true,
"funding": [
{
"type": "github",
@@ -1821,7 +1955,12 @@
"version": "2.0.4",
"resolved": "http://verdaccio:4873/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"dev": true,
"license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "http://verdaccio:4873/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/is-callable": {
@@ -1934,6 +2073,18 @@
"node": ">= 0.4"
}
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "http://verdaccio:4873/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimatch": {
"version": "9.0.9",
"resolved": "http://verdaccio:4873/minimatch/-/minimatch-9.0.9.tgz",
@@ -1950,6 +2101,15 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "http://verdaccio:4873/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "http://verdaccio:4873/minipass/-/minipass-7.1.3.tgz",
@@ -1960,6 +2120,12 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "http://verdaccio:4873/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "http://verdaccio:4873/ms/-/ms-2.1.3.tgz",
@@ -1986,6 +2152,33 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/napi-build-utils": {
"version": "1.0.2",
"resolved": "http://verdaccio:4873/napi-build-utils/-/napi-build-utils-1.0.2.tgz",
"integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==",
"license": "MIT"
},
"node_modules/node-abi": {
"version": "3.94.0",
"resolved": "http://verdaccio:4873/node-abi/-/node-abi-3.94.0.tgz",
"integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "http://verdaccio:4873/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "http://verdaccio:4873/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -2096,6 +2289,71 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/prebuild-install": {
"version": "7.1.2",
"resolved": "http://verdaccio:4873/prebuild-install/-/prebuild-install-7.1.2.tgz",
"integrity": "sha512-UnNke3IQb6sgarcZIDU3gbMeTp/9SSU1DAIkil7PrqG1vZlBtY5msYccSKSHDqa3hNg436IXK+SNImReuA1wEQ==",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^1.0.1",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "http://verdaccio:4873/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "http://verdaccio:4873/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "http://verdaccio:4873/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "http://verdaccio:4873/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
@@ -2163,7 +2421,6 @@
"version": "5.2.1",
"resolved": "http://verdaccio:4873/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"dev": true,
"funding": [
{
"type": "github",
@@ -2180,6 +2437,18 @@
],
"license": "MIT"
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "http://verdaccio:4873/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "http://verdaccio:4873/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -2262,6 +2531,51 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "http://verdaccio:4873/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "http://verdaccio:4873/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "http://verdaccio:4873/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2303,6 +2617,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "http://verdaccio:4873/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "http://verdaccio:4873/string-width/-/string-width-5.1.2.tgz",
@@ -2407,6 +2730,15 @@
"node": ">=8"
}
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "http://verdaccio:4873/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/strip-literal": {
"version": "3.1.0",
"resolved": "http://verdaccio:4873/strip-literal/-/strip-literal-3.1.0.tgz",
@@ -2420,6 +2752,34 @@
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/tar-fs": {
"version": "2.1.5",
"resolved": "http://verdaccio:4873/tar-fs/-/tar-fs-2.1.5.tgz",
"integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "http://verdaccio:4873/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "http://verdaccio:4873/tinybench/-/tinybench-2.9.0.tgz",
@@ -2503,6 +2863,18 @@
"dev": true,
"license": "0BSD"
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "http://verdaccio:4873/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/typed-array-buffer": {
"version": "1.0.3",
"resolved": "http://verdaccio:4873/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
@@ -2621,6 +2993,12 @@
}
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "http://verdaccio:4873/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/uuid": {
"version": "11.1.1",
"resolved": "http://verdaccio:4873/uuid/-/uuid-11.1.1.tgz",
@@ -2959,6 +3337,12 @@
"node": ">=8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "http://verdaccio:4873/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "http://verdaccio:4873/y18n/-/y18n-5.0.8.tgz",
+6 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@windychen/trans-form-typeorm",
"version": "0.1.0",
"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",
@@ -27,5 +28,8 @@
"devDependencies": {
"typeorm": "^0.3.20",
"vitest": "^3.2.4"
},
"dependencies": {
"better-sqlite3": "^12.11.1"
}
}
+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]),
+147 -59
View File
@@ -1,3 +1,4 @@
import { And } from 'typeorm';
import { mapOperator } from './operatorMap.js';
const NULL_QUERIES = new Set(['IS_NULL', 'IS_NOT_NULL']);
@@ -14,7 +15,6 @@ function isEmptyValue(value) {
}
/**
* Skip when value is empty, there are no `values`, and query is not a null-check.
* @param {object} filter
* @returns {boolean}
*/
@@ -27,26 +27,151 @@ function shouldSkipEmptyValue(filter) {
}
/**
* Convert parsed filters into TypeORM `FindOptionsWhere[]`.
* Merge a leaf entry into a group object, combining same-field keys with And().
* @param {Record<string, *>} target
* @param {Record<string, *>} entry
* @returns {Record<string, *>}
*/
function mergeEntry(target, entry) {
for (const [key, value] of Object.entries(entry)) {
if (key in target) {
target[key] = And(target[key], value);
} else {
target[key] = value;
}
}
return target;
}
/**
* Cartesian product: merge every element of `groups` with every element of `converted`.
* Used to distribute an outer AND group over an inner OR group:
* X AND (A OR B) => [{X,A}, {X,B}]
* @param {Record<string, *>[]} groups
* @param {Record<string, *>[]} converted
* @returns {Record<string, *>[]}
*/
function cartesian(groups, converted) {
const out = [];
for (const g of groups) {
for (const el of converted) {
out.push(mergeEntry({ ...g }, el));
}
}
return out;
}
/**
* Recursively build a TypeORM FindOptionsWhere object or array from a group
* of filters (flat list or nested `{ logic, children }` tree).
*
* AND items merge into the same object; OR items become separate array elements
* (`where: [{a:1},{b:2}]` ⇒ `a=1 OR b=2`). Pure AND returns `[{...}]`;
* empty input returns `[]`.
* Semantics:
* - AND items merge into the same object (`{a:1, b:2}`)
* - OR items become separate array elements (`[{a:1},{b:2}]`)
* - an inner OR group appearing after outer AND items is distributed
* via the cartesian product (`X AND (A OR B)` => `[{X,A},{X,B}]`)
* - same-field AND is combined with TypeORM `And()`
*
* The first accepted filter always starts an AND group (even if it carries `logic`).
* @param {Array<object>} items
* @param {{
* skipFlags?: string[],
* skipEmpty?: boolean,
* operatorMap?: Record<string, *>,
* }} options
* @returns {Record<string, *> | Record<string, *>[]}
*/
function buildGroup(items, options) {
const { skipFlags = ['disabled', 'hidden'], skipEmpty = false, operatorMap } = options;
/** @type {Record<string, *>} */
let obj = {};
/** @type {Record<string, *>[]} */
let parts = [];
let inOrMode = false;
const flushObj = () => {
if (Object.keys(obj).length > 0) {
parts.push(obj);
obj = {};
inOrMode = true;
}
};
for (const item of items) {
if (!item || typeof item !== 'object') continue;
if (skipFlags.some((flag) => item[flag] === true)) continue;
if (skipEmpty && item.empty === true) continue;
// Nested group node (toTree output): { logic, children }
let converted;
if (Array.isArray(item.children)) {
const childItems = item.children.filter((c) => {
if (!c || typeof c !== 'object') return false;
if (skipFlags.some((flag) => c[flag] === true)) return false;
if (skipEmpty && c.empty === true) return false;
if (c.field == null && !Array.isArray(c.children)) return false;
return true;
});
if (!childItems.length) continue;
converted = buildGroup(childItems, options);
} else {
if (shouldSkipEmptyValue(item)) continue;
if (item.field == null || item.query == null) continue;
const factory = mapOperator(item.query, operatorMap);
const findOperator = factory(item.value, item.values);
converted = { [String(item.field)]: findOperator };
}
const logic = String(item.logic || 'AND').toUpperCase();
if (logic === 'OR' && Object.keys(obj).length > 0) {
flushObj();
}
if (Array.isArray(converted)) {
// Inner OR group — distribute over accumulated parts / obj.
if (parts.length > 0) {
parts = cartesian(parts, converted);
} else if (Object.keys(obj).length > 0) {
parts = converted.map((el) => mergeEntry({ ...obj }, el));
obj = {};
} else {
parts = converted;
}
inOrMode = true;
} else if (logic === 'OR') {
// New OR branch — push as a separate array element.
parts.push(converted);
inOrMode = true;
} else if (parts.length > 0 && inOrMode) {
// AND connection in OR mode — distribute into every element.
parts = parts.map((p) => mergeEntry({ ...p }, converted));
} else {
// Plain AND — merge into the current object.
mergeEntry(obj, converted);
}
}
flushObj();
// A single-element array only happens when the whole group was plain AND
// (flushObj pushed one object). Unwrap it so the caller does not treat it
// as an OR group and run the cartesian product over it.
if (inOrMode) {
return parts.length === 1 ? parts[0] : parts;
}
return obj;
}
/**
* Convert parsed filters (flat `parseFilters` output or nested `toTree` output)
* into TypeORM `FindOptionsWhere[]`.
*
* @param {Array<{
* field: string,
* query: string,
* op?: string,
* value?: *,
* values?: *[],
* logic?: string,
* empty?: boolean,
* disabled?: boolean,
* hidden?: boolean,
* readOnly?: boolean,
* }>} filters - `@windychen/trans-form-middleware` `parseFilters` output
* Supports deep OR nesting via recursive expansion:
* - `A OR B` => `[{A}, {B}]`
* - `X AND (A OR B)` => `[{X,A}, {X,B}]` (distributive law)
* - `(A OR B) AND (C OR D)` => `[{A,C},{A,D},{B,C},{B,D}]`
*
* @param {Array<object>} filters
* @param {{
* skipFlags?: string[],
* skipEmpty?: boolean,
@@ -55,47 +180,10 @@ function shouldSkipEmptyValue(filter) {
* @returns {import('typeorm').FindOptionsWhere<*>[]}
*/
export function toFindOptionsWhere(filters, options = {}) {
const {
skipFlags = ['disabled', 'hidden'],
skipEmpty = false,
operatorMap,
} = options;
if (!Array.isArray(filters) || filters.length === 0) return [];
/** @type {Record<string, *>[]} */
const groups = [];
/** @type {Record<string, *>} */
let current = {};
const flush = () => {
if (Object.keys(current).length > 0) {
groups.push(current);
current = {};
}
};
for (const filter of filters) {
if (!filter || typeof filter !== 'object') continue;
if (skipFlags.some((flag) => filter[flag] === true)) continue;
if (skipEmpty && filter.empty === true) continue;
if (shouldSkipEmptyValue(filter)) continue;
if (filter.field == null || filter.query == null) continue;
const factory = mapOperator(filter.query, operatorMap);
const findOperator = factory(filter.value, filter.values);
const entry = { [String(filter.field)]: findOperator };
// First group starts as AND regardless of `logic` on the first item.
const logic = String(filter.logic || 'AND').toUpperCase();
if (logic === 'OR' && Object.keys(current).length > 0) {
flush();
}
Object.assign(current, entry);
}
flush();
return groups;
const built = buildGroup(filters, options);
if (Array.isArray(built)) return built;
if (Object.keys(built).length === 0) return [];
return [built];
}
+149
View File
@@ -139,6 +139,128 @@ 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 嵌套', () => {
it('X AND (A OR B) 分配律展开为 [{X,A},{X,B}]', () => {
const out = toFindOptionsWhere([
{ field: 'x', query: 'EQ', value: 1 },
{
logic: 'AND',
children: [
{ field: 'a', query: 'EQ', value: 2 },
{ field: 'b', query: 'EQ', value: 3, logic: 'OR' },
],
},
]);
expect(out).toHaveLength(2);
expect(out[0].x.value).toBe(1);
expect(out[0].a.value).toBe(2);
expect(out[1].x.value).toBe(1);
expect(out[1].b.value).toBe(3);
});
it('(A OR B) AND (C OR D) 笛卡尔积展开', () => {
const out = toFindOptionsWhere([
{
logic: 'AND',
children: [
{ field: 'a', query: 'EQ', value: 1 },
{ field: 'b', query: 'EQ', value: 2, logic: 'OR' },
],
},
{
logic: 'AND',
children: [
{ field: 'c', query: 'EQ', value: 3 },
{ field: 'd', query: 'EQ', value: 4, logic: 'OR' },
],
},
]);
expect(out).toHaveLength(4);
// [{A,C},{A,D},{B,C},{B,D}]
const combos = out.map((g) => Object.keys(g).sort().join(','));
expect(combos).toEqual(expect.arrayContaining(['a,c', 'a,d', 'b,c', 'b,d']));
});
it('A OR (B AND C) 保持两组', () => {
const out = toFindOptionsWhere([
{ field: 'a', query: 'EQ', value: 1 },
{
logic: 'OR',
children: [
{ field: 'b', query: 'EQ', value: 2 },
{ field: 'c', query: 'EQ', value: 3, logic: 'AND' },
],
},
]);
expect(out).toHaveLength(2);
expect(out[0]).toEqual({ a: Equal(1) });
expect(out[1].b.value).toBe(2);
expect(out[1].c.value).toBe(3);
});
it('同字段 AND 用 And() 组合', () => {
const out = toFindOptionsWhere([
{ field: 'name', query: 'EQ', value: 'a' },
{ field: 'name', query: 'LIKE', value: 'b', logic: 'AND' },
]);
expect(out).toHaveLength(1);
expect(out[0].name.type).toBe('and');
});
});
describe('toFindOptionsWhere 真实 SQL 验证', () => {
it('深层嵌套生成正确 SQL', async () => {
const { DataSource, EntitySchema } = await import('typeorm');
const User = new EntitySchema({
name: 'User',
tableName: 'users',
columns: {
id: { type: 'int', primary: true, generated: true },
name: { type: 'varchar' },
age: { type: 'int' },
},
});
const ds = new DataSource({
type: 'better-sqlite3',
database: ':memory:',
entities: [User],
synchronize: true,
});
await ds.initialize();
try {
const repo = ds.getRepository('User');
const where = toFindOptionsWhere([
{ field: 'name', query: 'EQ', value: 'a' },
{
logic: 'AND',
children: [
{ field: 'age', query: 'GT', value: 18 },
{ field: 'age', query: 'LT', value: 60, logic: 'OR' },
],
},
]);
const qb = repo.createQueryBuilder('u');
qb.where(where);
const [sql, params] = qb.getQueryAndParameters();
// 分配律: X AND (A OR B) => (X AND A) OR (X AND B)
expect(sql).toContain('("u"."name" = ? AND "u"."age" > 18)');
expect(sql).toContain('("u"."name" = ? AND "u"."age" < 60)');
expect(sql).toContain(' OR ');
expect(params.filter((p) => p === 'a')).toHaveLength(2);
} finally {
await ds.destroy();
}
});
});
describe('applyToQueryBuilder', () => {
@@ -217,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, [