@comodinx/query-filters
Version:
@comodinx/query-filters is a module for parsing filters in string to object.
473 lines (472 loc) • 16.4 kB
JavaScript
"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;