UNPKG

@comodinx/query-filters

Version:

@comodinx/query-filters is a module for parsing filters in string to object.

473 lines (472 loc) 16.4 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.Parser = void 0; const formatter_1 = require("./formatter"); const constants_1 = require("./constants"); const helpers_1 = require("./helpers"); const regexpSpace = /\s/; const regexpListGroupStart = /^\[/; const regexpListGroupEnd = /\]$/; class Parser { constructor(options = {}) { this.options = { ...constants_1.defaultOptions, ...(options || {}) }; const ops = []; // Compile regexp for operators this.options.operators.forEach((operator) => ops.push(`${this.options.operatorPrefix}${this.toStringOperator(operator)}${this.options.operatorSuffix}`)); // Instantiate new regexp with key + operators + value this.regexpOperatorPrefix = new RegExp(`^${this.options.operatorPrefix}`); this.regexpOperatorSuffix = new RegExp(`${this.options.operatorSuffix}$`); this.regexpFilters = new RegExp(`^(${this.options.key})(${ops.join("|")})(${this.options.value})$`, this.options.operatorFlags); } static get defaults() { return constants_1.defaultOptions; } /** * Public parse * - Legacy: if no OR/group/word-ops, behaves exactly like old parser (split by comma => AND). * - Expr mode: tokenizes + shunting-yard + AST compile. */ parse(str) { // Verify str content if (!str?.length) { return; } const input = String(str).trim(); if (!input?.length) { return; } // 1) Tokenize const tokens = this.tokenizeExpression(str); if (!tokens.length) { return; } // 2) Convert to RPN (precedence AND > OR) const rpn = this.toRpn(tokens); if (!rpn.length) { return; } // 3) Build AST const ast = this.rpnToAst(rpn); if (!ast) { return; } // 4) Compile AST -> object (flatten where possible) const compiled = this.compileAst(ast); if (!compiled || !Object.keys(compiled).length) { return; } return compiled; } /** * Tokenizer for: * - AND: ',' OR ' AND ' (spaces required around word operator) * - OR: '|' OR ' OR ' (spaces required around word operator) * - Groups: '(' ')' * * Produces tokens: ATOM, AND, OR, LPAREN, RPAREN */ tokenizeExpression(input) { const tokens = []; let buf = ""; let i = 0; const pushAtom = () => { const s = buf.trim(); if (s?.length) { tokens.push({ type: "ATOM", text: s }); } buf = ""; }; const matchOperator = (operator) => { // eslint-disable-next-line no-useless-escape const rexexpOperator = new RegExp(`^\\\s+${operator}\\\s+`, "i"); const matches = input.slice(i).match(rexexpOperator); return matches ? matches[0] : null; }; while (i < input.length) { const ch = input[i]; // Groups if (ch === this.options.groups.start) { pushAtom(); tokens.push({ type: "LPAREN" }); i += 1; continue; } if (ch === this.options.groups.end) { pushAtom(); tokens.push({ type: "RPAREN" }); i += 1; continue; } // OR symbol if (ch === this.options.logicals.or) { pushAtom(); tokens.push({ type: "OR" }); i += 1; continue; } // AND via comma if (ch === this.options.separator || ch === this.options.logicals.and) { pushAtom(); tokens.push({ type: "AND" }); i += 1; continue; } // Word operators (only if spaces around) if (regexpSpace.test(ch)) { const andMatch = matchOperator("$and"); if (andMatch) { pushAtom(); tokens.push({ type: "AND" }); i += andMatch.length; continue; } const orMatch = matchOperator("$or"); if (orMatch) { pushAtom(); tokens.push({ type: "OR" }); i += orMatch.length; continue; } } // default buf += ch; i += 1; } pushAtom(); // Minimal cleanup: remove dangling operators and obvious invalid placements // (No strict errors for now; we keep behavior forgiving.) return tokens.filter((t, i) => { if (t.type === "AND" || t.type === "OR") { const prev = tokens[i - 1]; const next = tokens[i + 1]; if (!prev || !next) { return false; } if (prev.type !== "ATOM" && prev.type !== "RPAREN") { return false; } if (next.type !== "ATOM" && next.type !== "LPAREN") { return false; } } return true; }); } /** * Shunting-yard: tokens -> RPN * precedence: AND (2) > OR (1) */ toRpn(tokens) { const output = []; const stack = []; const prec = (t) => (t.type === "AND" ? 2 : t.type === "OR" ? 1 : 0); for (const t of tokens) { if (t.type === "ATOM") { output.push(t); continue; } if (t.type === "LPAREN") { stack.push(t); continue; } if (t.type === "RPAREN") { while (stack.length && stack[stack.length - 1].type !== "LPAREN") { output.push(stack.pop()); } if (stack.length && stack[stack.length - 1].type === "LPAREN") { stack.pop(); } continue; } if (t.type === "AND" || t.type === "OR") { while (stack.length && (stack[stack.length - 1].type === "AND" || stack[stack.length - 1].type === "OR") && prec(stack[stack.length - 1]) >= prec(t)) { output.push(stack.pop()); } stack.push(t); } } while (stack.length) { const op = stack.pop(); if (op.type !== "LPAREN" && op.type !== "RPAREN") { output.push(op); } } return output; } /** * RPN -> AST * Node shapes: * - { type:'ATOM', obj } * - { type:'AND', items:[...] } * - { type:'OR', items:[...] } */ rpnToAst(rpn) { const stack = []; for (const token of rpn) { if (token.type === "ATOM") { const obj = this.parseAtom(token.text); if (obj.skip) { stack.push({ type: "ATOM", skip: true }); } else if (obj) { stack.push({ type: "ATOM", obj }); } continue; } if (token.type === "AND" || token.type === "OR") { const right = stack.pop(); const left = stack.pop(); if (!left || !right) { continue; } const type = token.type === "AND" ? "AND" : "OR"; const items = []; // flatten same operator nodes if (left.type === type) { items.push(...left.items); } else { items.push(left); } if (right.type === type) { items.push(...right.items); } else { items.push(right); } stack.push({ type, items }); } } return stack[0]; } /** * Parse atomic condition using existing regex+mappers. * "active eq 1" => { active: { eq: "1" } } */ parseAtom(text) { const chunk = String(text).trim(); if (!chunk) { return; } const parsed = this.regexpFilters.exec(chunk); if (!parsed) { return { skip: true }; } const [_, key, op, value] = parsed; const operator = op.trim(); const mappedKey = this.mapKey(key, operator, constants_1.methodParse); return { [mappedKey]: { [this.mapOperator(operator, constants_1.methodParse)]: this.mapValue(value, operator, constants_1.methodParse) } }; } /** * Compile AST to final JSON * - OR: { or: [ ... ] } * - AND: * - if possible returns plain merged object (legacy feel) * - if mix with logical nodes, returns { and: [...] } unless it can be flattened as { ...base, or:[...] } */ compileAst(node) { if (!node) { return; } if (node.type === "ATOM") { if (node.skip) { return; } return node.obj; } const orKey = this.options.logicals.orKey || "or"; if (node.type === "OR") { const items = node.items.map((n) => this.compileAst(n)).filter(Boolean); if (!items.length) { return; } if (items.length === 1) { return items[0]; } return { [orKey]: items }; } const andKey = this.options.logicals.andKey || "and"; if (node.type === "AND") { const compiledItems = node.items.map((n) => this.compileAst(n)).filter(Boolean); if (!compiledItems.length) { return; } if (compiledItems.length === 1) { return compiledItems[0]; } const base = {}; const logical = []; for (const it of compiledItems) { const isLogical = it && (Object.prototype.hasOwnProperty.call(it, orKey) || Object.prototype.hasOwnProperty.call(it, andKey)); if (isLogical) { logical.push(it); } else { this.mergePlainInto(base, it); } } // Flatten: base AND (OR ...) => { ...base, or: [...] } if (logical.length === 1 && logical[0][orKey] && Object.keys(base).length) { return { ...base, [orKey]: logical[0][orKey] }; } // Only plain if (!logical.length) { return base; } // General: explicit AND list const andItems = []; if (Object.keys(base).length) { andItems.push(base); } andItems.push(...logical); return { [andKey]: andItems }; } } /** * Merge plain filter objects preserving your operator-merge behavior: * { description:{li:"%casa"} } + { description:{eq:"depto"} } => { description:{li:"%casa", eq:"depto"} } */ mergePlainInto(target, incoming) { if (!incoming || !(0, helpers_1.isObject)(incoming)) { return; } Object.keys(incoming).forEach((k) => { const next = incoming[k]; if (!target[k]) { target[k] = next; return; } if ((0, helpers_1.isObject)(target[k]) && (0, helpers_1.isObject)(next)) { target[k] = { ...target[k], ...next }; return; } target[k] = next; }); } /** * Format filters */ format(filters) { return new formatter_1.Formatter(this.options).format(filters); } /** * Map key */ mapKey(key, operator, method) { let mapper = this.options.mapKey; if (!mapper) { switch (method) { case constants_1.methodFormat: mapper = this.options.mapKeyFormat; break; case constants_1.methodParse: default: mapper = this.options.mapKeyParse; break; } } // Check if key mapper is function if ((0, helpers_1.isFunction)(mapper)) { return mapper(key, operator, method); } // We assume that key mapper is an object if ((0, helpers_1.isObject)(mapper)) { return mapper[key] ?? key; } return key; } /** * Map value */ mapValue(value, operator, method) { let mapper = this.options.mapValue; if (!mapper) { switch (method) { case constants_1.methodFormat: mapper = this.options.mapValueFormat; break; case constants_1.methodParse: default: if (this.options.operatorsNeedJsonParse.includes(operator)) { value = value .toString() .trim() .replace(regexpListGroupStart, "") .replace(regexpListGroupEnd, "") .split(this.options.separatorGroups) .map((v) => v.trim()); } mapper = this.options.mapValueParse; break; } } // Check if key mapper is function if ((0, helpers_1.isFunction)(mapper)) { return mapper(value, operator, method); } // We assume that key mapper is an object if ((0, helpers_1.isObject)(mapper)) { return mapper[value.toString()] ?? value; } if (method === constants_1.methodFormat) { if (Array.isArray(value)) { return `[${value.join(this.options.separatorGroups)}]`; } if ((0, helpers_1.isObject)(value)) { return JSON.stringify(value); } } return value; } /** * Map operator */ mapOperator(operator, method) { // Clean operator if (method === constants_1.methodParse) { operator = this.toStringOperator(operator); } // Supports backward compatibility this.options.mapOperator = this.options.mapOperator || this.options.mapper; // Check if has any mapper if (!this.options.mapOperator) { return operator; } // Check if mapper is function if ((0, helpers_1.isFunction)(this.options.mapOperator)) { return this.options.mapOperator(operator, method); } // We assume that mapper is an object switch (method) { case constants_1.methodFormat: if (!this.options.mapOperatorInverse && (0, helpers_1.isObject)(this.options.mapOperator)) { this.options.mapOperatorInverse = (0, helpers_1.invert)(this.options.mapOperator); } return (this.options.mapOperatorInverse?.[operator] || this.options.mapOperatorInverse?.[this.toStringOperator(operator)] || operator); case constants_1.methodParse: default: return (this.options.mapOperator[operator] || this.options.mapOperator[this.toStringOperator(operator)] || operator); } } toStringOperator(operator) { return ((0, helpers_1.isSymbol)(operator) ? Symbol.keyFor(operator) : operator) .toString() .trim() .toLowerCase() .replace(this.regexpOperatorPrefix, "") .replace(this.regexpOperatorSuffix, ""); } } exports.Parser = Parser;