chore: 版本 0.1.1 更新 toFindOptionsWhere 支持深层 OR 嵌套分配律展开
Node.js Build / build (push) Successful in 6s

This commit is contained in:
2026-08-07 11:36:07 +08:00
parent 324b995f05
commit bafa6198e5
6 changed files with 670 additions and 67 deletions
+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];
}