propero-oss/easy-filter

View on GitHub
src/parser/parser.ts

Summary

Maintainability
A
1 hr
Test Coverage
F
0%
import { Filter } from "../types";
import { defaultProcessors } from "../processor";
import { FilterParserToken, FilterProcessor, UnprocessedFilter } from "./types";
import { TOKEN_PARAMS_START, TOKEN_PARAMS_END, TOKEN_PARAMS_SEPARATOR } from "./constants";
import { UnsupportedOperationError, IllegalArgumentsError } from "../errors";

export class FilterParser {
  protected readonly tokenLookup: Record<string, FilterParserToken>;
  protected readonly processorMap: Record<string, FilterProcessor>;
  protected readonly processorAliasMap: Record<string, FilterProcessor>;
  protected readonly escapingRegExp: RegExp;

  public constructor(
    protected paramsStartChar = "(",
    protected paramsEndChar = ")",
    protected paramsSeparatorChar = ",",
    protected escapeChar = "\\",
    protected processors = defaultProcessors
  ) {
    this.tokenLookup = {
      [paramsStartChar]: TOKEN_PARAMS_START,
      [paramsEndChar]: TOKEN_PARAMS_END,
      [paramsSeparatorChar]: TOKEN_PARAMS_SEPARATOR,
    };
    this.escapingRegExp = this.buildEscapingRegExp();
    this.processorMap = this.buildProcessorMap();
    this.processorAliasMap = this.buildProcessorAliasMap();
  }

  protected buildProcessorMap(): Record<string, FilterProcessor> {
    return this.processors.map((it) => [it.operator, it] as const).reduce((all, [key, value]) => ({ ...all, [key]: value }), {});
  }

  protected buildProcessorAliasMap(): Record<string, FilterProcessor> {
    return this.processors.map((it) => [it.alias, it] as const).reduce((all, [key, value]) => ({ ...all, [key]: value }), {});
  }

  protected buildEscapingRegExp(): RegExp {
    const { paramsStartChar, paramsEndChar, paramsSeparatorChar, escapeChar } = this;
    const escaped = `${paramsStartChar}${paramsEndChar}${paramsSeparatorChar}${escapeChar}`.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&");
    return new RegExp(`[${escaped}]`, "g");
  }

  protected tokenize(raw: string): (string | FilterParserToken)[] {
    const { paramsStartChar, paramsEndChar, paramsSeparatorChar, escapeChar, tokenLookup } = this;
    const tokens: (string | FilterParserToken)[] = [];
    let esc = false;
    let current = "";
    let tokenBefore = false;
    for (const char of raw) {
      if (char === escapeChar && (esc = !esc)) continue;
      if ((char === paramsStartChar || char === paramsEndChar || char === paramsSeparatorChar) && !esc) {
        if (!tokenBefore) tokens.push(current, tokenLookup[char]);
        else tokens.push(tokenLookup[char]);
        tokenBefore = true;
        current = "";
      } else {
        current += char;
        tokenBefore = false;
      }
      esc = false;
    }
    return tokens;
  }

  protected segment(tokens: (string | FilterParserToken)[]): UnprocessedFilter {
    const branch = (op: string) => ({ op, params: [] } as UnprocessedFilter);
    const root = branch("and");
    const stack = [root];
    let current = root;
    let prev: string | FilterParserToken = "";
    for (const token of tokens) {
      if (token === TOKEN_PARAMS_START) {
        current.params.push((current = branch(prev as string)));
        stack.push(current);
      } else if (token === TOKEN_PARAMS_END) {
        if (prev !== TOKEN_PARAMS_START && prev !== TOKEN_PARAMS_END) current.params.push(prev as string);
        stack.pop();
        current = stack[stack.length - 1];
      } else if (token === TOKEN_PARAMS_SEPARATOR) {
        if (prev !== TOKEN_PARAMS_END) current.params.push(prev as string);
      }
      prev = token;
    }
    return root;
  }

  protected process(raw: UnprocessedFilter): Filter {
    const { params, op } = raw;
    if ((op === "and" || op === "or") && params.length === 1) return this.process(params[0] as UnprocessedFilter);
    const processor = this.processorAliasMap[op];
    if (!processor) throw new UnsupportedOperationError(`Unsupported filter operator: ${op}`);
    if (!processor.validateParams(...params)) throw new IllegalArgumentsError(`Invalid filter arguments for filter: ${op}`);
    return processor.process(this.process.bind(this), ...params);
  }

  public parse(filter: string): Filter {
    const tokens = this.tokenize(filter);
    const segments = this.segment(tokens);
    return this.process(segments);
  }

  protected escape(str: string): string {
    return str.replace(this.escapingRegExp, `\\$&`);
  }

  public serialize(filter: Filter): string {
    const { escape, serialize, paramsStartChar, paramsSeparatorChar, paramsEndChar, processorMap } = this;
    const processor = processorMap[filter.op];
    const [alias, ...params] = [processor.alias, ...processor.serializeParams(serialize.bind(this), escape.bind(this), filter)];
    return [alias, paramsStartChar, params.join(paramsSeparatorChar), paramsEndChar].join("");
  }
}