- 基于 grill-me Q1-Q61 决策 - Vitest 测试与 CI workflow - middleware: parseFilters 核心实现 - component: Vue3 TransForm 基础组件 + Storybook Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,131 @@
|
|||||||
|
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-middleware.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: |
|
||||||
|
npm config set registry http://verdaccio:4873/
|
||||||
|
npm config set //verdaccio:4873/:_authToken ${{ secrets.NPM_TOKEN }}
|
||||||
|
|
||||||
|
- 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-middleware" -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-middleware/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-middleware/releases" \
|
||||||
|
-u "${{ secrets.CI_USER }}:${{ secrets.CI_PASSWORD }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\":\"v${VERSION}\",\"name\":\"v${VERSION}\",\"body\":\"## trans-form-middleware 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-middleware v${VERSION} 构建完成 chen-fnos"
|
||||||
|
else
|
||||||
|
TITLE="❌ trans-form-middleware 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-middleware\n- 分支: ${{ github.ref_name }}\n- 提交: ${{ github.sha }}\"}}"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
coverage/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# trans-form-middleware — Design (Q1–Q61)
|
||||||
|
|
||||||
|
Status: **implementation scaffold** (v0.1.0)
|
||||||
|
|
||||||
|
Companion package: `@windychen/trans-form-component` (Vue 3 dynamic query form).
|
||||||
|
|
||||||
|
## Role
|
||||||
|
|
||||||
|
Receive component filter output → structured `{ field, query, op, value, logic?, values?, empty? }[]`. **No SQL generation.**
|
||||||
|
|
||||||
|
## Key decisions
|
||||||
|
|
||||||
|
| # | Decision |
|
||||||
|
|---|----------|
|
||||||
|
| 1–2 | Node.js pure JS; structured filters only |
|
||||||
|
| 8 | Dual fields: `query` (code) + `op` (symbol) |
|
||||||
|
| 14 | `mode`: pass-through (default) / flat / tree |
|
||||||
|
| 24–25 | Zero deps; ESM only; no bundling (`src/index.js`) |
|
||||||
|
| 38 | `empty: true` preserved by default |
|
||||||
|
| 39 | Built-in `DEFAULT_OPERATOR_MAP`, merge via `operatorMap` |
|
||||||
|
| 41 | Multi-function exports: `parseFilters`, `toTree`, `mapOperators`, … |
|
||||||
|
| 44 | `strict` default false |
|
||||||
|
| 46 | Multi-value: `value` (string) + `values` (parsed array) |
|
||||||
|
| 49–50 | `allowedFields` required; `unknownField: 'error'` default |
|
||||||
|
| 60 | Component emits all state flags; middleware skips `disabled` by default |
|
||||||
|
| 61 | Also skips `hidden`; `skipEmpty` option controls empty items |
|
||||||
|
|
||||||
|
## Output example
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"field": "tags",
|
||||||
|
"query": "IN",
|
||||||
|
"op": "IN",
|
||||||
|
"value": "a,b,c",
|
||||||
|
"values": ["a", "b", "c"],
|
||||||
|
"logic": "OR"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Operator map
|
||||||
|
|
||||||
|
`EQ→=`, `NE→<>`, `GT→>`, `GTE→>=`, `LT→<`, `LTE→<=`, `LIKE→LIKE`, `IN→IN`, `NOT_IN→NOT IN`, `BETWEEN→BETWEEN`, `IS_NULL→IS NULL`, `IS_NOT_NULL→IS NOT NULL`
|
||||||
|
|
||||||
|
## CI
|
||||||
|
|
||||||
|
Copy of windychen-utils pattern: commit message must contain `chore` + `版本` + `更新` to trigger publish.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# @windychen/trans-form-middleware
|
||||||
|
|
||||||
|
Node.js ESM library that parses filter arrays emitted by `@windychen/trans-form-component` into structured conditions. **Does not generate SQL** — ORM adapters consume the output.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @windychen/trans-form-middleware
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { parseFilters } from '@windychen/trans-form-middleware';
|
||||||
|
|
||||||
|
const componentOutput = [
|
||||||
|
{ field: 'name', value: 'xx', query: 'EQ' },
|
||||||
|
{ field: 'tags', value: 'a,b,c', query: 'IN', logic: 'OR' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const filters = parseFilters(componentOutput, {
|
||||||
|
allowedFields: ['name', 'tags', 'status'],
|
||||||
|
mode: 'pass-through',
|
||||||
|
unknownField: 'error',
|
||||||
|
skipEmpty: false,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
See `DESIGN.md` for the full protocol and grill-me decisions (Q1–Q61).
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| Export | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `parseFilters(conditions, options)` | Main parser |
|
||||||
|
| `mapOperators(query, operatorMap?)` | Map EQ/GT/… to op symbols |
|
||||||
|
| `toTree(flat)` | Nest flat array by logic |
|
||||||
|
| `splitMultiValue(query, value)` | Parse comma-separated multi-values |
|
||||||
|
| `DEFAULT_OPERATOR_MAP` | Built-in operator mapping |
|
||||||
|
|
||||||
|
### parseOptions
|
||||||
|
|
||||||
|
| Option | Default | Description |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| `allowedFields` | *(required)* | Whitelist of field names |
|
||||||
|
| `mode` | `'pass-through'` | `'flat'` \| `'tree'` \| `'pass-through'` |
|
||||||
|
| `unknownField` | `'error'` | `'error'` \| `'skip'` |
|
||||||
|
| `strict` | `false` | Validate item shape |
|
||||||
|
| `operatorMap` | `{}` | Merge/override operator mapping |
|
||||||
|
| `skipFlags` | `['disabled','hidden']` | Skip items with these truthy flags |
|
||||||
|
| `skipEmpty` | `false` | Skip items with `empty: true` |
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"row": 6,
|
||||||
|
"logic": "AND",
|
||||||
|
"value-component": "input",
|
||||||
|
"field": "name",
|
||||||
|
"field-name": "名称",
|
||||||
|
"query-option": [
|
||||||
|
{ "value": "EQ", "label": "=", "desc-cn": "等于" },
|
||||||
|
{ "value": "LIKE", "label": "~", "desc-cn": "包含" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"row": 6,
|
||||||
|
"logic": "AND",
|
||||||
|
"value-component": "select",
|
||||||
|
"field": "status",
|
||||||
|
"field-name": "状态",
|
||||||
|
"query-option": [
|
||||||
|
{ "value": "EQ", "label": "=", "desc-cn": "等于" },
|
||||||
|
{ "value": "NE", "label": "≠", "desc-cn": "不等于" }
|
||||||
|
],
|
||||||
|
"options": [
|
||||||
|
{ "value": "active", "label": "启用" },
|
||||||
|
{ "value": "inactive", "label": "停用" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"row": 2,
|
||||||
|
"logic": "OR",
|
||||||
|
"value-component": "input",
|
||||||
|
"field": "tags",
|
||||||
|
"field-name": "标签",
|
||||||
|
"in-ui": "tags",
|
||||||
|
"query-option": [
|
||||||
|
{ "value": "IN", "label": "∈", "desc-cn": "包含任一" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"row": 9,
|
||||||
|
"logic": "OR",
|
||||||
|
"value-component": "input",
|
||||||
|
"field": "created",
|
||||||
|
"field-name": "创建时间",
|
||||||
|
"between-ui": "dual-input",
|
||||||
|
"query-option": [
|
||||||
|
{ "value": "BETWEEN", "label": "↔", "desc-cn": "区间" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
Generated
+1503
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "@windychen/trans-form-middleware",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Parse trans-form-component filter output into structured conditions (no SQL)",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.js"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"src"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"filter",
|
||||||
|
"query",
|
||||||
|
"middleware",
|
||||||
|
"trans-form"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"vitest": "^3.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { DEFAULT_OPERATOR_MAP, mapOperators } from './operatorMap.js';
|
||||||
|
export { splitMultiValue } from './splitMultiValue.js';
|
||||||
|
export { toTree } from './toTree.js';
|
||||||
|
export { parseFilters } from './parseFilters.js';
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/** @type {Record<string, string>} */
|
||||||
|
export const DEFAULT_OPERATOR_MAP = {
|
||||||
|
EQ: '=',
|
||||||
|
NE: '<>',
|
||||||
|
GT: '>',
|
||||||
|
GTE: '>=',
|
||||||
|
LT: '<',
|
||||||
|
LTE: '<=',
|
||||||
|
LIKE: 'LIKE',
|
||||||
|
IN: 'IN',
|
||||||
|
NOT_IN: 'NOT IN',
|
||||||
|
BETWEEN: 'BETWEEN',
|
||||||
|
IS_NULL: 'IS NULL',
|
||||||
|
IS_NOT_NULL: 'IS NOT NULL',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} query
|
||||||
|
* @param {Record<string, string>} [operatorMap]
|
||||||
|
*/
|
||||||
|
export function mapOperators(query, operatorMap = DEFAULT_OPERATOR_MAP) {
|
||||||
|
const q = String(query).toUpperCase();
|
||||||
|
const merged = { ...DEFAULT_OPERATOR_MAP, ...operatorMap };
|
||||||
|
return merged[q] ?? q;
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { mapOperators } from './operatorMap.js';
|
||||||
|
import { splitMultiValue } from './splitMultiValue.js';
|
||||||
|
import { toTree } from './toTree.js';
|
||||||
|
|
||||||
|
const DEFAULT_SKIP_FLAGS = ['disabled', 'hidden'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} item
|
||||||
|
* @param {string[]} skipFlags
|
||||||
|
*/
|
||||||
|
function shouldSkipByFlags(item, skipFlags) {
|
||||||
|
return skipFlags.some((flag) => item[flag] === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Array<{field?: string, value?: *, query?: string, logic?: string, empty?: boolean, disabled?: boolean, hidden?: boolean}>} conditions
|
||||||
|
* @param {{
|
||||||
|
* allowedFields: string[],
|
||||||
|
* mode?: 'pass-through'|'flat'|'tree',
|
||||||
|
* unknownField?: 'error'|'skip',
|
||||||
|
* strict?: boolean,
|
||||||
|
* operatorMap?: Record<string, string>,
|
||||||
|
* skipFlags?: string[],
|
||||||
|
* skipEmpty?: boolean,
|
||||||
|
* }} options
|
||||||
|
*/
|
||||||
|
export function parseFilters(conditions, options) {
|
||||||
|
const {
|
||||||
|
allowedFields,
|
||||||
|
mode = 'pass-through',
|
||||||
|
unknownField = 'error',
|
||||||
|
strict = false,
|
||||||
|
operatorMap = {},
|
||||||
|
skipFlags = DEFAULT_SKIP_FLAGS,
|
||||||
|
skipEmpty = false,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (!Array.isArray(allowedFields) || allowedFields.length === 0) {
|
||||||
|
throw new Error('parseFilters: allowedFields is required');
|
||||||
|
}
|
||||||
|
if (!Array.isArray(conditions)) {
|
||||||
|
if (strict) throw new Error('parseFilters: conditions must be an array');
|
||||||
|
return mode === 'tree' ? [] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowed = new Set(allowedFields.map(String));
|
||||||
|
const errors = [];
|
||||||
|
const out = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < conditions.length; i++) {
|
||||||
|
const c = conditions[i];
|
||||||
|
if (!c || typeof c !== 'object') {
|
||||||
|
if (strict) errors.push({ index: i, reason: 'invalid item' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (shouldSkipByFlags(c, skipFlags)) continue;
|
||||||
|
if (skipEmpty && c.empty === true) continue;
|
||||||
|
|
||||||
|
if (c.field == null || c.query == null) {
|
||||||
|
if (strict) errors.push({ index: i, reason: 'missing field or query' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const field = String(c.field);
|
||||||
|
if (!allowed.has(field)) {
|
||||||
|
if (unknownField === 'error') {
|
||||||
|
throw new Error(`parseFilters: unknown field "${field}"`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = String(c.query).toUpperCase();
|
||||||
|
const item = {
|
||||||
|
field,
|
||||||
|
query,
|
||||||
|
op: mapOperators(query, operatorMap),
|
||||||
|
value: c.value ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsed = splitMultiValue(query, c.value);
|
||||||
|
if (parsed != null) item.values = parsed;
|
||||||
|
|
||||||
|
if (c.empty === true) item.empty = true;
|
||||||
|
if (c.disabled === true) item.disabled = true;
|
||||||
|
if (c.hidden === true) item.hidden = true;
|
||||||
|
if (c.readOnly === true) item.readOnly = true;
|
||||||
|
if (c.logic != null) item.logic = c.logic;
|
||||||
|
|
||||||
|
out.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strict && errors.length) {
|
||||||
|
throw new Error(`parseFilters: ${errors.length} validation error(s)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'flat') {
|
||||||
|
return out.map(({ logic, ...rest }) => rest);
|
||||||
|
}
|
||||||
|
if (mode === 'tree') {
|
||||||
|
return toTree(out);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* @param {string} query
|
||||||
|
* @param {string|null|undefined} value
|
||||||
|
* @returns {string[]|[string,string]|null}
|
||||||
|
*/
|
||||||
|
export function splitMultiValue(query, value) {
|
||||||
|
if (value == null || value === '') return null;
|
||||||
|
const s = String(value);
|
||||||
|
const q = String(query).toUpperCase();
|
||||||
|
if (q === 'IN' || q === 'NOT_IN') {
|
||||||
|
return s.split(',').map((v) => v.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
if (q === 'BETWEEN') {
|
||||||
|
const parts = s.split(',').map((v) => v.trim());
|
||||||
|
return parts.length >= 2 ? [parts[0], parts[1]] : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* @param {Array<object>} flat
|
||||||
|
* @returns {Array<object>}
|
||||||
|
*/
|
||||||
|
export function toTree(flat) {
|
||||||
|
if (!flat.length) return [];
|
||||||
|
if (flat.length === 1) return [flat[0]];
|
||||||
|
|
||||||
|
let acc = flat[0];
|
||||||
|
for (let i = 1; i < flat.length; i++) {
|
||||||
|
const logic = flat[i].logic || 'AND';
|
||||||
|
acc = { logic, children: [acc, flat[i]] };
|
||||||
|
}
|
||||||
|
return [acc];
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
DEFAULT_OPERATOR_MAP,
|
||||||
|
mapOperators,
|
||||||
|
parseFilters,
|
||||||
|
splitMultiValue,
|
||||||
|
toTree,
|
||||||
|
} from '../src/index.js';
|
||||||
|
|
||||||
|
describe('mapOperators', () => {
|
||||||
|
it('maps known query codes', () => {
|
||||||
|
expect(mapOperators('EQ')).toBe('=');
|
||||||
|
expect(mapOperators('NOT_IN')).toBe('NOT IN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows custom overrides', () => {
|
||||||
|
expect(mapOperators('EQ', { EQ: 'equals' })).toBe('equals');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('splitMultiValue', () => {
|
||||||
|
it('splits IN values', () => {
|
||||||
|
expect(splitMultiValue('IN', 'a,b,c')).toEqual(['a', 'b', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('splits BETWEEN values', () => {
|
||||||
|
expect(splitMultiValue('BETWEEN', '2024-01-01,2024-12-31')).toEqual([
|
||||||
|
'2024-01-01',
|
||||||
|
'2024-12-31',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseFilters', () => {
|
||||||
|
const allowedFields = ['name', 'status', 'tags', 'created'];
|
||||||
|
|
||||||
|
it('requires allowedFields', () => {
|
||||||
|
expect(() => parseFilters([], {})).toThrow(/allowedFields/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses basic conditions with logic pass-through', () => {
|
||||||
|
const input = [
|
||||||
|
{ field: 'name', value: 'xx', query: 'EQ' },
|
||||||
|
{ field: 'tags', value: 'a,b', query: 'IN', logic: 'OR' },
|
||||||
|
];
|
||||||
|
const out = parseFilters(input, { allowedFields });
|
||||||
|
expect(out).toEqual([
|
||||||
|
{
|
||||||
|
field: 'name',
|
||||||
|
query: 'EQ',
|
||||||
|
op: '=',
|
||||||
|
value: 'xx',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'tags',
|
||||||
|
query: 'IN',
|
||||||
|
op: 'IN',
|
||||||
|
value: 'a,b',
|
||||||
|
values: ['a', 'b'],
|
||||||
|
logic: 'OR',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves empty markers by default', () => {
|
||||||
|
const out = parseFilters(
|
||||||
|
[{ field: 'name', value: null, query: 'EQ', empty: true }],
|
||||||
|
{ allowedFields },
|
||||||
|
);
|
||||||
|
expect(out[0].empty).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips disabled and hidden by default', () => {
|
||||||
|
const out = parseFilters(
|
||||||
|
[
|
||||||
|
{ field: 'name', value: 'a', query: 'EQ', disabled: true },
|
||||||
|
{ field: 'status', value: 'b', query: 'EQ', hidden: true },
|
||||||
|
{ field: 'tags', value: 'c', query: 'EQ' },
|
||||||
|
],
|
||||||
|
{ allowedFields },
|
||||||
|
);
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out[0].field).toBe('tags');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can skip empty items when skipEmpty is true', () => {
|
||||||
|
const out = parseFilters(
|
||||||
|
[{ field: 'name', value: null, query: 'EQ', empty: true }],
|
||||||
|
{ allowedFields, skipEmpty: true },
|
||||||
|
);
|
||||||
|
expect(out).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on unknown field by default', () => {
|
||||||
|
expect(() =>
|
||||||
|
parseFilters([{ field: 'unknown', value: 'x', query: 'EQ' }], {
|
||||||
|
allowedFields,
|
||||||
|
}),
|
||||||
|
).toThrow(/unknown field/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports flat mode', () => {
|
||||||
|
const out = parseFilters(
|
||||||
|
[{ field: 'name', value: 'x', query: 'EQ', logic: 'AND' }],
|
||||||
|
{ allowedFields, mode: 'flat' },
|
||||||
|
);
|
||||||
|
expect(out[0].logic).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toTree', () => {
|
||||||
|
it('nests flat filters', () => {
|
||||||
|
const flat = [
|
||||||
|
{ field: 'a', query: 'EQ', op: '=', value: '1' },
|
||||||
|
{ field: 'b', query: 'EQ', op: '=', value: '2', logic: 'OR' },
|
||||||
|
];
|
||||||
|
const tree = toTree(flat);
|
||||||
|
expect(tree[0]).toMatchObject({
|
||||||
|
logic: 'OR',
|
||||||
|
children: expect.any(Array),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DEFAULT_OPERATOR_MAP', () => {
|
||||||
|
it('includes IS_NULL operators', () => {
|
||||||
|
expect(DEFAULT_OPERATOR_MAP.IS_NULL).toBe('IS NULL');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user