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,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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user