This commit is contained in:
+147
-59
@@ -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];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user