1 Commits

Author SHA1 Message Date
windychen bafa6198e5 chore: 版本 0.1.1 更新 toFindOptionsWhere 支持深层 OR 嵌套分配律展开
Node.js Build / build (push) Successful in 6s
2026-08-07 11:36:07 +08:00
6 changed files with 670 additions and 67 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`(括号表达式,无展开成本)
+15 -1
View File
@@ -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",
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windychen/trans-form-typeorm",
"version": "0.1.0",
"version": "0.1.1",
"description": "TypeORM adapter for @windychen/trans-form-middleware: structured filters to FindOptionsWhere / QueryBuilder",
"type": "module",
"exports": {
@@ -27,5 +27,8 @@
"devDependencies": {
"typeorm": "^0.3.20",
"vitest": "^3.2.4"
},
"dependencies": {
"better-sqlite3": "^12.11.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];
}
+114
View File
@@ -141,6 +141,120 @@ describe('toFindOptionsWhere', () => {
});
});
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', () => {
it('单 EQ → andWhere("name = :p1", {p1:"xx"})', () => {
const qb = createMockQb();