From bafa6198e5e134a5e09e843df04c4a55db1c815d Mon Sep 17 00:00:00 2001 From: windychen Date: Fri, 7 Aug 2026 11:36:07 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E7=89=88=E6=9C=AC=200.1.1=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=20toFindOptionsWhere=20=E6=94=AF=E6=8C=81=E6=B7=B1?= =?UTF-8?q?=E5=B1=82=20OR=20=E5=B5=8C=E5=A5=97=E5=88=86=E9=85=8D=E5=BE=8B?= =?UTF-8?q?=E5=B1=95=E5=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DESIGN.md | 4 +- README.md | 16 +- package-lock.json | 392 +++++++++++++++++++++++++++++++++- package.json | 5 +- src/toFindOptionsWhere.js | 206 +++++++++++++----- test/transFormTypeorm.test.js | 114 ++++++++++ 6 files changed, 670 insertions(+), 67 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index a27d75a..2792e35 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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`(括号表达式,无展开成本) diff --git a/README.md b/README.md index 1d5469e..9376cb5 100644 --- a/README.md +++ b/README.md @@ -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 }` diff --git a/package-lock.json b/package-lock.json index da84829..0916885 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index f3d7594..7fcc6b1 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/toFindOptionsWhere.js b/src/toFindOptionsWhere.js index a5da81e..6150057 100644 --- a/src/toFindOptionsWhere.js +++ b/src/toFindOptionsWhere.js @@ -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} target + * @param {Record} entry + * @returns {Record} + */ +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[]} groups + * @param {Record[]} converted + * @returns {Record[]} + */ +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} items + * @param {{ + * skipFlags?: string[], + * skipEmpty?: boolean, + * operatorMap?: Record, + * }} options + * @returns {Record | Record[]} + */ +function buildGroup(items, options) { + const { skipFlags = ['disabled', 'hidden'], skipEmpty = false, operatorMap } = options; + + /** @type {Record} */ + let obj = {}; + /** @type {Record[]} */ + 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} 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[]} */ - const groups = []; - /** @type {Record} */ - 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]; } - diff --git a/test/transFormTypeorm.test.js b/test/transFormTypeorm.test.js index d9fa368..a37e893 100644 --- a/test/transFormTypeorm.test.js +++ b/test/transFormTypeorm.test.js @@ -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();