chore: 版本 0.1.0 更新 TypeORM 适配包 toFindOptionsWhere/applyToQueryBuilder
Node.js Build / build (push) Successful in 5s
Node.js Build / build (push) Successful in 5s
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
name: Node.js Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main", "master"]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: contains(github.event.head_commit.message, 'chore') && contains(github.event.head_commit.message, '版本') && contains(github.event.head_commit.message, '更新')
|
||||
runs-on: docker
|
||||
steps:
|
||||
- name: Checkout code
|
||||
env:
|
||||
GIT_AUTH: ${{ secrets.CI_USER }}:${{ secrets.CI_PASSWORD }}
|
||||
run: |
|
||||
git init
|
||||
git remote add origin http://gitea:3000/windychen/trans-form-typeorm.git
|
||||
AUTH_B64=$(printf '%s' "$GIT_AUTH" | base64 | tr -d '\n')
|
||||
git -c http.extraHeader="Authorization: Basic ${AUTH_B64}" fetch --depth 1 origin ${{ github.sha }}
|
||||
git checkout ${{ github.sha }}
|
||||
|
||||
- name: Setup npm for Verdaccio
|
||||
run: |
|
||||
AUTH_B64=$(printf '%s' "${{ secrets.VERDACCIO_USER }}:${{ secrets.VERDACCIO_PASSWORD }}" | base64 | tr -d '\n')
|
||||
npm config set registry http://verdaccio:4873/
|
||||
npm config set //verdaccio:4873/:_auth "$AUTH_B64"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm i --registry http://verdaccio:4873/
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
- name: Publish to Verdaccio
|
||||
id: publish-verdaccio
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
PKG=$(node -p "require('./package.json').name.replace('@windychen/','')")
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://verdaccio:4873/@windychen%2f${PKG}/$VERSION || echo 404)
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "Verdaccio: version $VERSION already exists, skipping."
|
||||
echo "verdaccio_result=skipped|版本 ${VERSION} 已存在" >> $GITHUB_OUTPUT
|
||||
else
|
||||
if npm publish --access public 2>&1; then
|
||||
echo "verdaccio_result=published|已发布 v${VERSION}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "verdaccio_result=failed|发布失败" >> $GITHUB_OUTPUT
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Publish to Gitea
|
||||
id: publish-gitea
|
||||
env:
|
||||
GITEA_NPM_AUTH: ${{ secrets.CI_USER }}:${{ secrets.NPM_GITEA_TOKEN }}
|
||||
run: |
|
||||
AUTH_B64=$(printf '%s' "$GITEA_NPM_AUTH" | base64 | tr -d '\n')
|
||||
npm config set //gitea:3000/api/packages/windychen/npm/:_auth "$AUTH_B64"
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
PKG=$(node -p "require('./package.json').name")
|
||||
EXISTS=$(curl -s "http://gitea:3000/api/v1/packages/windychen?q=trans-form-typeorm" -u "${{ secrets.CI_USER }}:${{ secrets.CI_PASSWORD }}" | grep -c "\"version\":\"$VERSION\"" || true)
|
||||
if [ "$EXISTS" -gt 0 ]; then
|
||||
echo "Gitea: version $VERSION already exists, skipping."
|
||||
echo "gitea_result=skipped|版本 ${VERSION} 已存在" >> $GITHUB_OUTPUT
|
||||
else
|
||||
if npm publish --registry http://gitea:3000/api/packages/windychen/npm/ 2>&1; then
|
||||
echo "gitea_result=published|已发布 v${VERSION}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "gitea_result=failed|发布失败" >> $GITHUB_OUTPUT
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Create Gitea Release
|
||||
if: always() && steps.publish-gitea.outcome == 'success'
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
EXISTS=$(curl -s "http://gitea:3000/api/v1/repos/windychen/trans-form-typeorm/releases/tags/v${VERSION}" -u "${{ secrets.CI_USER }}:${{ secrets.CI_PASSWORD }}" | grep -c "\"tag_name\"" || true)
|
||||
if [ "$EXISTS" = "0" ]; then
|
||||
curl -s -X POST \
|
||||
"http://gitea:3000/api/v1/repos/windychen/trans-form-typeorm/releases" \
|
||||
-u "${{ secrets.CI_USER }}:${{ secrets.CI_PASSWORD }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"v${VERSION}\",\"name\":\"v${VERSION}\",\"body\":\"## trans-form-typeorm v${VERSION}\",\"target_commitish\":\"main\"}"
|
||||
else
|
||||
echo "Release v${VERSION} already exists, skipping."
|
||||
fi
|
||||
|
||||
- name: Notify DingTalk
|
||||
if: always()
|
||||
run: |
|
||||
STATUS="${{ job.status }}"
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
|
||||
parse_result() {
|
||||
local raw="$1"
|
||||
local target="$2"
|
||||
local icon="${raw%%|*}"
|
||||
local detail="${raw#*|}"
|
||||
case "$icon" in
|
||||
published) echo "📦 ${target}: ${detail}" ;;
|
||||
skipped) echo "⏭️ ${target}: ${detail}" ;;
|
||||
failed) echo "❌ ${target}: ${detail}" ;;
|
||||
*) echo "⚠️ ${target}: ${detail}" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
VERD_RESULT="${{ steps.publish-verdaccio.outputs.verdaccio_result }}"
|
||||
GITEA_RESULT="${{ steps.publish-gitea.outputs.gitea_result }}"
|
||||
|
||||
if [ -n "$VERD_RESULT" ]; then
|
||||
VERD_LINE=$(parse_result "$VERD_RESULT" "Verdaccio")
|
||||
else
|
||||
VERD_LINE="⚠️ Verdaccio: 未执行"
|
||||
fi
|
||||
|
||||
if [ -n "$GITEA_RESULT" ]; then
|
||||
GITEA_LINE=$(parse_result "$GITEA_RESULT" "Gitea")
|
||||
else
|
||||
GITEA_LINE="⚠️ Gitea: 未执行"
|
||||
fi
|
||||
|
||||
if [ "$STATUS" = "success" ]; then
|
||||
TITLE="✅ trans-form-typeorm v${VERSION} 构建完成 chen-fnos"
|
||||
else
|
||||
TITLE="❌ trans-form-typeorm v${VERSION} 构建失败 chen-fnos"
|
||||
fi
|
||||
|
||||
curl -s -X POST "https://oapi.dingtalk.com/robot/send?access_token=${{ secrets.DINGDING_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"msgtype\":\"markdown\",\"markdown\":{\"title\":\"${TITLE}\",\"text\":\"## ${TITLE}\n\n${VERD_LINE}\n${GITEA_LINE}\n\n- 仓库: windychen/trans-form-typeorm\n- 分支: ${{ github.ref_name }}\n- 提交: ${{ github.sha }}\"}}"
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
*.log
|
||||
.DS_Store
|
||||
.npmrc
|
||||
@@ -0,0 +1,44 @@
|
||||
# @windychen/trans-form-typeorm — Design
|
||||
|
||||
> 设计基线:grill-me 61 题访谈结论(2026-08-06),决策 #24("middleware 零依赖,另做 ORM 适配包")的 TypeORM 落地。
|
||||
|
||||
## 角色定位
|
||||
|
||||
接收 `@windychen/trans-form-middleware` 的 `parseFilters` 输出,转为 TypeORM 可消费的查询条件。**不重新解析语义** —— 校验、白名单、状态标记过滤都在 middleware 层完成,本包只做「结构化 → TypeORM」。
|
||||
|
||||
## 核心决策
|
||||
|
||||
| # | 决策 |
|
||||
|---|------|
|
||||
| 1 | **独立 Gitea 仓库**,独立 CI、独立发版(沿用 Q3 模式) |
|
||||
| 2 | 零运行时依赖;`typeorm@^0.3` 为 peerDependency |
|
||||
| 3 | ESM only,不打包(`exports: "./src/index.js"`) |
|
||||
| 4 | 两种输出:`FindOptionsWhere[]`(对象式)+ QueryBuilder 条件(表达式式) |
|
||||
| 5 | AND 项合并同一对象;OR 项拆数组元素(TypeORM 语义) |
|
||||
| 6 | 嵌套分组(Q6)用括号表达式递归生成 |
|
||||
| 7 | 参数化绑定(`:p1 :p2`),杜绝字符串拼接注入 |
|
||||
| 8 | 空 value 且非 NULL 查询自动跳过;LIKE 自动补 `%` |
|
||||
| 9 | `disabled`/`hidden` 默认跳过(对齐 Q60/Q61),`skipEmpty` 可配 |
|
||||
| 10 | 测试用真实 typeorm(FindOperator 断言),Vitest |
|
||||
|
||||
## API 表面
|
||||
|
||||
| 函数 | 签名 | 说明 |
|
||||
|------|------|------|
|
||||
| `toFindOptionsWhere` | `(filters, { skipFlags, skipEmpty, operatorMap })` | → `FindOptionsWhere[]` |
|
||||
| `applyToQueryBuilder` | `(qb, filters, { skipFlags, skipEmpty, operatorMap, alias, paramPrefix, method })` | → `{ qb, parameters }` |
|
||||
| `mapOperator` | `(query, operatorMap?)` | 操作符码 → 工厂函数 |
|
||||
| `DEFAULT_OPERATOR_MAP` | — | 12 种操作符映射 |
|
||||
|
||||
## 操作符覆盖(对齐 middleware 码表)
|
||||
|
||||
EQ NE GT GTE LT LTE LIKE IN NOT_IN BETWEEN IS_NULL IS_NOT_NULL
|
||||
|
||||
## CI
|
||||
|
||||
复制 windychen-utils 模式:commit message 含 `chore` + `版本` + `更新` 触发 → Verdaccio + Gitea Packages + Release + 钉钉通知。
|
||||
|
||||
## 未决项 / 后续
|
||||
|
||||
- TypeORM `FindOptionsWhere` 对深层 OR 嵌套(如 `A OR (B AND C)` 内嵌)表达能力有限,深层场景建议走 `applyToQueryBuilder`
|
||||
- 可选:`trans-form-knex`(决策 #24 提及的另一适配方向)
|
||||
@@ -0,0 +1,80 @@
|
||||
# @windychen/trans-form-typeorm
|
||||
|
||||
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 全部操作符。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
npm install @windychen/trans-form-typeorm typeorm
|
||||
```
|
||||
|
||||
## 用法
|
||||
|
||||
```js
|
||||
import { parseFilters } from '@windychen/trans-form-middleware';
|
||||
import { toFindOptionsWhere, applyToQueryBuilder } from '@windychen/trans-form-typeorm';
|
||||
|
||||
// 1. middleware 解析
|
||||
const filters = parseFilters(
|
||||
[
|
||||
{ field: 'name', value: 'xx', query: 'EQ' },
|
||||
{ field: 'tags', value: 'a,b', query: 'IN', logic: 'OR' },
|
||||
{ field: 'created', value: '2024-01-01,2024-12-31', query: 'BETWEEN', logic: 'AND' },
|
||||
],
|
||||
{ allowedFields: ['name', 'tags', 'created'] },
|
||||
);
|
||||
|
||||
// 2a. FindOptionsWhere(repository.find)
|
||||
const where = toFindOptionsWhere(filters);
|
||||
// [{ name: Equal('xx') }, { tags: In(['a','b']), created: Between('2024-01-01','2024-12-31') }]
|
||||
await repo.find({ where });
|
||||
|
||||
// 2b. QueryBuilder(支持嵌套分组)
|
||||
const { qb, parameters } = applyToQueryBuilder(repo.createQueryBuilder('u'), filters, { alias: 'u' });
|
||||
// u.name = :p1 OR (u.tags IN (:...p2) AND u.created BETWEEN :p3 AND :p4)
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
| 函数 | 说明 |
|
||||
|------|------|
|
||||
| `toFindOptionsWhere(filters, options?)` | → `FindOptionsWhere[]`。AND 项合并进同一对象,OR 项拆成数组元素(`where: [{a:1},{b:2}]` = a=1 OR b=2) |
|
||||
| `applyToQueryBuilder(qb, filters, options?)` | → `{ qb, parameters }`。支持嵌套分组括号表达式、参数绑定、alias 前缀 |
|
||||
| `mapOperator(query, operatorMap?)` | 操作符码 → 工厂函数 |
|
||||
| `DEFAULT_OPERATOR_MAP` | 默认操作符映射表 |
|
||||
|
||||
### 选项
|
||||
|
||||
**toFindOptionsWhere**:`{ skipFlags = ['disabled','hidden'], skipEmpty = false, operatorMap }`
|
||||
|
||||
**applyToQueryBuilder**:`{ skipFlags = ['disabled','hidden'], skipEmpty = false, operatorMap, alias = '', paramPrefix = 'p', method = 'andWhere' }`
|
||||
- `method`: `'where' | 'andWhere' | 'orWhere'`,首条件挂载方式
|
||||
- 空 value 且非 IS_NULL/IS_NOT_NULL 的条件自动跳过;LIKE 值自动加 `%`
|
||||
|
||||
## 操作符映射
|
||||
|
||||
| query | FindOptionsWhere | SQL |
|
||||
|-------|------------------|-----|
|
||||
| EQ | `Equal(v)` | `= :p` |
|
||||
| NE | `Not(Equal(v))` | `<> :p` |
|
||||
| GT | `MoreThan(v)` | `> :p` |
|
||||
| GTE | `MoreThanOrEqual(v)` | `>= :p` |
|
||||
| LT | `LessThan(v)` | `< :p` |
|
||||
| LTE | `LessThanOrEqual(v)` | `<= :p` |
|
||||
| LIKE | `Like(v)` | `LIKE :p`(自动加 %) |
|
||||
| IN | `In(values)` | `IN (:...p)` |
|
||||
| NOT_IN | `Not(In(values))` | `NOT IN (:...p)` |
|
||||
| BETWEEN | `Between(a, b)` | `BETWEEN :p1 AND :p2` |
|
||||
| IS_NULL | `IsNull()` | `IS NULL` |
|
||||
| IS_NOT_NULL | `Not(IsNull())` | `IS NOT NULL` |
|
||||
|
||||
## 依赖
|
||||
|
||||
零运行时依赖;`typeorm` 为 peerDependency(`^0.3.0`)。
|
||||
|
||||
## 相关包
|
||||
|
||||
- `@windychen/trans-form-component` — Vue 3 动态查询表单(schema 入 → filters 出)
|
||||
- `@windychen/trans-form-middleware` — 结构化过滤器解析(不碰 SQL)
|
||||
Generated
+3047
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@windychen/trans-form-typeorm",
|
||||
"version": "0.1.0",
|
||||
"description": "TypeORM adapter for @windychen/trans-form-middleware: structured filters to FindOptionsWhere / QueryBuilder",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.js"
|
||||
},
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"keywords": [
|
||||
"typeorm",
|
||||
"filter",
|
||||
"query",
|
||||
"trans-form",
|
||||
"adapter"
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"typeorm": "^0.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typeorm": "^0.3.20",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
const DEFAULT_SKIP_FLAGS = ['disabled', 'hidden'];
|
||||
const NULL_QUERIES = new Set(['IS_NULL', 'IS_NOT_NULL']);
|
||||
|
||||
/**
|
||||
* Whether a scalar filter value is considered empty.
|
||||
* @param {*} value
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isEmptyValue(value) {
|
||||
if (value == null) return true;
|
||||
if (typeof value === 'string' && value.trim() === '') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} item
|
||||
* @param {string[]} skipFlags
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function shouldSkipByFlags(item, skipFlags) {
|
||||
return skipFlags.some((flag) => item[flag] === true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} filter
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function shouldSkipEmptyValue(filter) {
|
||||
const q = String(filter.query || '').toUpperCase();
|
||||
if (NULL_QUERIES.has(q)) return false;
|
||||
const hasValues = Array.isArray(filter.values) && filter.values.length > 0;
|
||||
if (hasValues) return false;
|
||||
return isEmptyValue(filter.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} alias
|
||||
* @param {string} field
|
||||
* @returns {string}
|
||||
*/
|
||||
function fieldRef(alias, field) {
|
||||
return alias ? `${alias}.${field}` : field;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {*} value
|
||||
* @returns {string}
|
||||
*/
|
||||
function likeValue(value) {
|
||||
const s = String(value ?? '');
|
||||
if (s.includes('%')) return s;
|
||||
return `%${s}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the SQL expression and parameters for a single leaf filter.
|
||||
* @param {object} filter
|
||||
* @param {object} ctx
|
||||
* @returns {{ expr: string, params: Record<string, *> } | null}
|
||||
*/
|
||||
function buildLeaf(filter, ctx) {
|
||||
const { alias, paramPrefix } = ctx;
|
||||
const q = String(filter.query || '').toUpperCase();
|
||||
const ref = fieldRef(alias, filter.field);
|
||||
|
||||
if (q === 'IS_NULL') return { expr: `${ref} IS NULL`, params: {} };
|
||||
if (q === 'IS_NOT_NULL') return { expr: `${ref} IS NOT NULL`, params: {} };
|
||||
|
||||
const value = filter.value;
|
||||
const values = Array.isArray(filter.values) ? filter.values : [];
|
||||
|
||||
if (q === 'IN' || q === 'NOT_IN') {
|
||||
if (!values.length) return null;
|
||||
const param = ctx.nextParam();
|
||||
const op = q === 'IN' ? 'IN' : 'NOT IN';
|
||||
return { expr: `${ref} ${op} (:...${param})`, params: { [param]: values } };
|
||||
}
|
||||
|
||||
if (q === 'BETWEEN') {
|
||||
if (values.length < 2) return null;
|
||||
const p1 = ctx.nextParam();
|
||||
const p2 = ctx.nextParam();
|
||||
return {
|
||||
expr: `${ref} BETWEEN :${p1} AND :${p2}`,
|
||||
params: { [p1]: values[0], [p2]: values[1] },
|
||||
};
|
||||
}
|
||||
|
||||
if (isEmptyValue(value)) return null;
|
||||
|
||||
const param = ctx.nextParam();
|
||||
const params = { [param]: q === 'LIKE' ? likeValue(value) : value };
|
||||
const ops = {
|
||||
EQ: '=',
|
||||
NE: '<>',
|
||||
GT: '>',
|
||||
GTE: '>=',
|
||||
LT: '<',
|
||||
LTE: '<=',
|
||||
LIKE: 'LIKE',
|
||||
};
|
||||
const op = ops[q];
|
||||
if (!op) {
|
||||
// Unknown query: fall back to equality.
|
||||
return { expr: `${ref} = :${param}`, params };
|
||||
}
|
||||
return { expr: `${ref} ${op} :${param}`, params };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply filters (flat list or nested `{ logic, children }` tree) onto a TypeORM QueryBuilder.
|
||||
*
|
||||
* Nested groups recursively build parenthesized expressions joined by the parent `logic`.
|
||||
* Sibling items at the same array level use each item's `logic` from the second accepted
|
||||
* item onward (first defaults to `options.method`).
|
||||
*
|
||||
* @param {import('typeorm').SelectQueryBuilder<*>} qb
|
||||
* @param {Array<object>} filters - `parseFilters` / `toTree` output
|
||||
* @param {{
|
||||
* skipFlags?: string[],
|
||||
* skipEmpty?: boolean,
|
||||
* operatorMap?: Record<string, *>,
|
||||
* alias?: string,
|
||||
* paramPrefix?: string,
|
||||
* method?: 'where'|'andWhere'|'orWhere',
|
||||
* }} [options]
|
||||
* @returns {{ qb: import('typeorm').SelectQueryBuilder<*>, parameters: Record<string, *> }}
|
||||
*/
|
||||
export function applyToQueryBuilder(qb, filters, options = {}) {
|
||||
const {
|
||||
skipFlags = DEFAULT_SKIP_FLAGS,
|
||||
skipEmpty = false,
|
||||
alias = '',
|
||||
paramPrefix = 'p',
|
||||
method = 'andWhere',
|
||||
} = options;
|
||||
|
||||
/** @type {Record<string, *>} */
|
||||
const parameters = {};
|
||||
let paramIndex = 0;
|
||||
|
||||
const ctx = {
|
||||
alias,
|
||||
paramPrefix,
|
||||
nextParam() {
|
||||
paramIndex += 1;
|
||||
return `${paramPrefix}${paramIndex}`;
|
||||
},
|
||||
};
|
||||
|
||||
const mountMethods = ['where', 'andWhere', 'orWhere'];
|
||||
if (!mountMethods.includes(method)) {
|
||||
throw new Error(`applyToQueryBuilder: invalid method "${method}"`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a clause for one item (leaf or nested group).
|
||||
* @param {object} item
|
||||
* @returns {{ expr: string, params: Record<string, *> } | null}
|
||||
*/
|
||||
function buildClause(item) {
|
||||
if (!item || typeof item !== 'object') return null;
|
||||
if (skipFlags.some((flag) => item[flag] === true)) return null;
|
||||
if (skipEmpty && item.empty === true) return null;
|
||||
|
||||
// Nested group node: { logic, children: [...] }
|
||||
if (Array.isArray(item.children)) {
|
||||
const childExprs = [];
|
||||
const childParams = {};
|
||||
let used = false;
|
||||
for (const child of item.children) {
|
||||
if (!child || typeof child !== 'object') continue;
|
||||
if (skipFlags.some((flag) => child[flag] === true)) continue;
|
||||
if (skipEmpty && child.empty === true) continue;
|
||||
if (shouldSkipEmptyValue(child)) continue;
|
||||
|
||||
const built = buildClause(child);
|
||||
if (!built) continue;
|
||||
|
||||
const childLogic = String(child.logic || 'AND').toUpperCase();
|
||||
const joiner = childLogic === 'OR' ? 'OR' : 'AND';
|
||||
if (used && childExprs.length) {
|
||||
childExprs.push(`${joiner} ${built.expr}`);
|
||||
} else {
|
||||
childExprs.push(built.expr);
|
||||
}
|
||||
Object.assign(childParams, built.params);
|
||||
used = true;
|
||||
}
|
||||
if (!childExprs.length) return null;
|
||||
return { expr: `(${childExprs.join(' ')})`, params: childParams };
|
||||
}
|
||||
|
||||
if (shouldSkipEmptyValue(item)) return null;
|
||||
if (item.field == null || item.query == null) return null;
|
||||
return buildLeaf(item, ctx);
|
||||
}
|
||||
|
||||
const clauses = [];
|
||||
for (const item of filters || []) {
|
||||
const built = buildClause(item);
|
||||
if (!built) continue;
|
||||
clauses.push({ item, built });
|
||||
}
|
||||
|
||||
if (!clauses.length) {
|
||||
return { qb, parameters };
|
||||
}
|
||||
|
||||
clauses.forEach(({ item, built }, idx) => {
|
||||
Object.assign(parameters, built.params);
|
||||
const logic = String(item.logic || 'AND').toUpperCase();
|
||||
if (idx === 0) {
|
||||
qb[method](built.expr, built.params);
|
||||
} else {
|
||||
const mount = logic === 'OR' ? 'orWhere' : 'andWhere';
|
||||
qb[mount](built.expr, built.params);
|
||||
}
|
||||
});
|
||||
|
||||
return { qb, parameters };
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { DEFAULT_OPERATOR_MAP, mapOperator } from './operatorMap.js';
|
||||
export { toFindOptionsWhere } from './toFindOptionsWhere.js';
|
||||
export { applyToQueryBuilder } from './applyToQueryBuilder.js';
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
Equal,
|
||||
Not,
|
||||
MoreThan,
|
||||
MoreThanOrEqual,
|
||||
LessThan,
|
||||
LessThanOrEqual,
|
||||
Like,
|
||||
In,
|
||||
Between,
|
||||
IsNull,
|
||||
} from 'typeorm';
|
||||
|
||||
/**
|
||||
* Default query-code → FindOperator factory map.
|
||||
* @type {Record<string, (value?: *, values?: *[]) => import('typeorm').FindOperator<*>>}
|
||||
*/
|
||||
export const DEFAULT_OPERATOR_MAP = {
|
||||
EQ: (value) => Equal(value),
|
||||
NE: (value) => Not(Equal(value)),
|
||||
GT: (value) => MoreThan(value),
|
||||
GTE: (value) => MoreThanOrEqual(value),
|
||||
LT: (value) => LessThan(value),
|
||||
LTE: (value) => LessThanOrEqual(value),
|
||||
LIKE: (value) => Like(value),
|
||||
IN: (_value, values) => In(values),
|
||||
NOT_IN: (_value, values) => Not(In(values)),
|
||||
BETWEEN: (_value, values) => Between(values[0], values[1]),
|
||||
IS_NULL: () => IsNull(),
|
||||
IS_NOT_NULL: () => Not(IsNull()),
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a query code to an operator factory, merging with defaults.
|
||||
* @param {string} query
|
||||
* @param {Record<string, (value?: *, values?: *[]) => *>} [operatorMap]
|
||||
* @returns {(value?: *, values?: *[]) => *}
|
||||
*/
|
||||
export function mapOperator(query, operatorMap = DEFAULT_OPERATOR_MAP) {
|
||||
const q = String(query).toUpperCase();
|
||||
const merged = { ...DEFAULT_OPERATOR_MAP, ...operatorMap };
|
||||
return merged[q] ?? DEFAULT_OPERATOR_MAP.EQ;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { mapOperator } from './operatorMap.js';
|
||||
|
||||
const NULL_QUERIES = new Set(['IS_NULL', 'IS_NOT_NULL']);
|
||||
|
||||
/**
|
||||
* Whether a scalar filter value is considered empty.
|
||||
* @param {*} value
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isEmptyValue(value) {
|
||||
if (value == null) return true;
|
||||
if (typeof value === 'string' && value.trim() === '') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip when value is empty, there are no `values`, and query is not a null-check.
|
||||
* @param {object} filter
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function shouldSkipEmptyValue(filter) {
|
||||
const q = String(filter.query || '').toUpperCase();
|
||||
if (NULL_QUERIES.has(q)) return false;
|
||||
const hasValues = Array.isArray(filter.values) && filter.values.length > 0;
|
||||
if (hasValues) return false;
|
||||
return isEmptyValue(filter.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert parsed filters into TypeORM `FindOptionsWhere[]`.
|
||||
*
|
||||
* 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 `[]`.
|
||||
*
|
||||
* The first accepted filter always starts an AND group (even if it carries `logic`).
|
||||
*
|
||||
* @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
|
||||
* @param {{
|
||||
* skipFlags?: string[],
|
||||
* skipEmpty?: boolean,
|
||||
* operatorMap?: Record<string, (value?: *, values?: *[]) => *>,
|
||||
* }} [options]
|
||||
* @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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
Equal,
|
||||
Not,
|
||||
MoreThan,
|
||||
MoreThanOrEqual,
|
||||
LessThan,
|
||||
LessThanOrEqual,
|
||||
Like,
|
||||
In,
|
||||
Between,
|
||||
IsNull,
|
||||
FindOperator,
|
||||
} from 'typeorm';
|
||||
import {
|
||||
toFindOptionsWhere,
|
||||
applyToQueryBuilder,
|
||||
DEFAULT_OPERATOR_MAP,
|
||||
mapOperator,
|
||||
} from '../src/index.js';
|
||||
|
||||
/**
|
||||
* @returns {{ where: ReturnType<typeof vi.fn>, andWhere: ReturnType<typeof vi.fn>, orWhere: ReturnType<typeof vi.fn> }}
|
||||
*/
|
||||
function createMockQb() {
|
||||
return {
|
||||
where: vi.fn().mockReturnThis(),
|
||||
andWhere: vi.fn().mockReturnThis(),
|
||||
orWhere: vi.fn().mockReturnThis(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('toFindOptionsWhere', () => {
|
||||
it('EQ 单条件 → Equal', () => {
|
||||
const out = toFindOptionsWhere([{ field: 'name', query: 'EQ', value: 'xx' }]);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].name).toBeInstanceOf(FindOperator);
|
||||
expect(out[0].name.type).toBe('equal');
|
||||
expect(out[0].name.value).toBe('xx');
|
||||
});
|
||||
|
||||
it('两个 AND 项合并到同一对象', () => {
|
||||
const out = toFindOptionsWhere([
|
||||
{ field: 'a', query: 'EQ', value: 1 },
|
||||
{ field: 'b', query: 'GT', value: 2, logic: 'AND' },
|
||||
]);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].a.type).toBe('equal');
|
||||
expect(out[0].a.value).toBe(1);
|
||||
expect(out[0].b.type).toBe('moreThan');
|
||||
expect(out[0].b.value).toBe(2);
|
||||
});
|
||||
|
||||
it('OR 项拆成数组两个元素', () => {
|
||||
const out = toFindOptionsWhere([
|
||||
{ field: 'a', query: 'EQ', value: 1 },
|
||||
{ field: 'b', query: 'EQ', value: 2, logic: 'OR' },
|
||||
]);
|
||||
expect(out).toHaveLength(2);
|
||||
expect(out[0]).toEqual({ a: Equal(1) });
|
||||
expect(out[1]).toEqual({ b: Equal(2) });
|
||||
});
|
||||
|
||||
it('IN 使用 values → In', () => {
|
||||
const out = toFindOptionsWhere([
|
||||
{ field: 'tags', query: 'IN', value: 'a,b', values: ['a', 'b'] },
|
||||
]);
|
||||
expect(out[0].tags.type).toBe('in');
|
||||
expect(out[0].tags.value).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('BETWEEN → Between', () => {
|
||||
const out = toFindOptionsWhere([
|
||||
{ field: 'n', query: 'BETWEEN', value: '1,10', values: ['1', '10'] },
|
||||
]);
|
||||
expect(out[0].n.type).toBe('between');
|
||||
expect(out[0].n.value).toEqual(['1', '10']);
|
||||
});
|
||||
|
||||
it('IS_NULL → IsNull', () => {
|
||||
const out = toFindOptionsWhere([{ field: 'd', query: 'IS_NULL' }]);
|
||||
expect(out[0].d.type).toBe('isNull');
|
||||
});
|
||||
|
||||
it('IS_NOT_NULL → Not(IsNull)', () => {
|
||||
const out = toFindOptionsWhere([{ field: 'd', query: 'IS_NOT_NULL' }]);
|
||||
expect(out[0].d.type).toBe('not');
|
||||
});
|
||||
|
||||
it('NE → Not(Equal)', () => {
|
||||
const out = toFindOptionsWhere([{ field: 's', query: 'NE', value: 'x' }]);
|
||||
expect(out[0].s.type).toBe('not');
|
||||
});
|
||||
|
||||
it('disabled/hidden 跳过', () => {
|
||||
const out = toFindOptionsWhere([
|
||||
{ field: 'a', query: 'EQ', value: 1, disabled: true },
|
||||
{ field: 'b', query: 'EQ', value: 2, hidden: true },
|
||||
{ field: 'c', query: 'EQ', value: 3 },
|
||||
]);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].c.value).toBe(3);
|
||||
});
|
||||
|
||||
it('skipEmpty:true 跳过 empty:true', () => {
|
||||
const out = toFindOptionsWhere(
|
||||
[
|
||||
{ field: 'a', query: 'EQ', value: null, empty: true },
|
||||
{ field: 'b', query: 'EQ', value: 2 },
|
||||
],
|
||||
{ skipEmpty: true },
|
||||
);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].b.value).toBe(2);
|
||||
});
|
||||
|
||||
it('空 value 且非 IS_NULL 跳过', () => {
|
||||
const out = toFindOptionsWhere([{ field: 'a', query: 'EQ', value: null }]);
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
|
||||
it('未知 query fallback → Equal', () => {
|
||||
const out = toFindOptionsWhere([{ field: 'a', query: 'FOO', value: 'x' }]);
|
||||
expect(out[0].a.type).toBe('equal');
|
||||
});
|
||||
|
||||
it('空输入 → []', () => {
|
||||
expect(toFindOptionsWhere([])).toEqual([]);
|
||||
expect(toFindOptionsWhere(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('GTE/LTE/LIKE 正确映射', () => {
|
||||
const out = toFindOptionsWhere([
|
||||
{ field: 'a', query: 'GTE', value: 1 },
|
||||
{ field: 'b', query: 'LTE', value: 2 },
|
||||
{ field: 'c', query: 'LIKE', value: 'xx' },
|
||||
]);
|
||||
expect(out[0].a.type).toBe('moreThanOrEqual');
|
||||
expect(out[0].b.type).toBe('lessThanOrEqual');
|
||||
expect(out[0].c.type).toBe('like');
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyToQueryBuilder', () => {
|
||||
it('单 EQ → andWhere("name = :p1", {p1:"xx"})', () => {
|
||||
const qb = createMockQb();
|
||||
const { parameters } = applyToQueryBuilder(qb, [
|
||||
{ field: 'name', query: 'EQ', value: 'xx' },
|
||||
]);
|
||||
expect(qb.andWhere).toHaveBeenCalledTimes(1);
|
||||
expect(qb.andWhere.mock.calls[0][0]).toBe('name = :p1');
|
||||
expect(qb.andWhere.mock.calls[0][1]).toEqual({ p1: 'xx' });
|
||||
expect(parameters).toEqual({ p1: 'xx' });
|
||||
});
|
||||
|
||||
it('LIKE 自动加 %', () => {
|
||||
const qb = createMockQb();
|
||||
applyToQueryBuilder(qb, [{ field: 'name', query: 'LIKE', value: 'xx' }]);
|
||||
expect(qb.andWhere.mock.calls[0][0]).toBe('name LIKE :p1');
|
||||
expect(qb.andWhere.mock.calls[0][1]).toEqual({ p1: '%xx%' });
|
||||
});
|
||||
|
||||
it('alias 前缀 → u.name = :p1', () => {
|
||||
const qb = createMockQb();
|
||||
applyToQueryBuilder(qb, [{ field: 'name', query: 'EQ', value: 'xx' }], { alias: 'u' });
|
||||
expect(qb.andWhere.mock.calls[0][0]).toBe('u.name = :p1');
|
||||
});
|
||||
|
||||
it('AND 同层:两次 andWhere', () => {
|
||||
const qb = createMockQb();
|
||||
applyToQueryBuilder(qb, [
|
||||
{ field: 'a', query: 'EQ', value: 1 },
|
||||
{ field: 'b', query: 'EQ', value: 2, logic: 'AND' },
|
||||
]);
|
||||
expect(qb.andWhere).toHaveBeenCalledTimes(2);
|
||||
expect(qb.orWhere).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('OR 同层:andWhere 后 orWhere', () => {
|
||||
const qb = createMockQb();
|
||||
applyToQueryBuilder(qb, [
|
||||
{ field: 'a', query: 'EQ', value: 1 },
|
||||
{ field: 'b', query: 'EQ', value: 2, logic: 'OR' },
|
||||
]);
|
||||
expect(qb.andWhere).toHaveBeenCalledTimes(1);
|
||||
expect(qb.orWhere).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('IN 展开 → tags IN (:...p1)', () => {
|
||||
const qb = createMockQb();
|
||||
applyToQueryBuilder(qb, [
|
||||
{ field: 'tags', query: 'IN', value: 'a,b', values: ['a', 'b'] },
|
||||
]);
|
||||
expect(qb.andWhere.mock.calls[0][0]).toBe('tags IN (:...p1)');
|
||||
expect(qb.andWhere.mock.calls[0][1]).toEqual({ p1: ['a', 'b'] });
|
||||
});
|
||||
|
||||
it('嵌套分组 → 括号表达式', () => {
|
||||
const qb = createMockQb();
|
||||
applyToQueryBuilder(qb, [
|
||||
{ field: 'a', query: 'EQ', value: 1 },
|
||||
{
|
||||
logic: 'OR',
|
||||
children: [
|
||||
{ field: 'b', query: 'EQ', value: 2 },
|
||||
{ field: 'c', query: 'EQ', value: 3, logic: 'AND' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(qb.andWhere).toHaveBeenCalledTimes(1);
|
||||
expect(qb.orWhere).toHaveBeenCalledTimes(1);
|
||||
const groupExpr = qb.orWhere.mock.calls[0][0];
|
||||
expect(groupExpr).toContain('(');
|
||||
expect(groupExpr).toContain(')');
|
||||
// b AND c inside the group, joined to a by OR
|
||||
expect(groupExpr).toContain('b = :p2');
|
||||
expect(groupExpr).toContain('c = :p3');
|
||||
});
|
||||
|
||||
it('参数递增 p1/p2', () => {
|
||||
const qb = createMockQb();
|
||||
const { parameters } = applyToQueryBuilder(qb, [
|
||||
{ field: 'a', query: 'EQ', value: 1 },
|
||||
{ field: 'b', query: 'EQ', value: 2, logic: 'AND' },
|
||||
]);
|
||||
expect(parameters.p1).toBe(1);
|
||||
expect(parameters.p2).toBe(2);
|
||||
});
|
||||
|
||||
it("method='where' 首条件用 where", () => {
|
||||
const qb = createMockQb();
|
||||
applyToQueryBuilder(qb, [{ field: 'a', query: 'EQ', value: 1 }], { method: 'where' });
|
||||
expect(qb.where).toHaveBeenCalledTimes(1);
|
||||
expect(qb.andWhere).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('空输入不调用 qb 方法', () => {
|
||||
const qb = createMockQb();
|
||||
const { parameters } = applyToQueryBuilder(qb, []);
|
||||
expect(qb.where).not.toHaveBeenCalled();
|
||||
expect(qb.andWhere).not.toHaveBeenCalled();
|
||||
expect(qb.orWhere).not.toHaveBeenCalled();
|
||||
expect(parameters).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('index exports', () => {
|
||||
it('DEFAULT_OPERATOR_MAP 含 IS_NULL/IS_NOT_NULL', () => {
|
||||
expect(DEFAULT_OPERATOR_MAP.IS_NULL).toBeTypeOf('function');
|
||||
expect(DEFAULT_OPERATOR_MAP.IS_NOT_NULL).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
it('mapOperator 返回工厂函数', () => {
|
||||
const factory = mapOperator('EQ');
|
||||
expect(factory).toBeTypeOf('function');
|
||||
expect(factory('x').type).toBe('equal');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user