Meefik's Blog

Freedom and Open Source

Human-readable MongoDB query syntax

24 Jun 2023 | javascript mongodb

Sometimes you need to give your application user a more flexible way to search the database. The search should be universal for any data and easy to understand for a person without technical knowledge.

I was able to create a simple query syntax and a parser to convert them to MongoDB query syntax. Below is a description of the query syntax and the parser code for them.

Query syntax

The query is a string that uses special characters. These symbols are similar in appearance and meaning to mathematical operations. This includes comparison operations, logical operations, grouping the order of operations.

Below is a table describing operators and use cases.

Operator Description Use case
> Greater field > 2021-12-01
< Less field < 5
>= Greater or equal field >= 10
<= Lessor equal field <= 50
= Equal field = "text"
!= Not equal field != "text"
~ Matches regular expression field ~ ^text\d+$
~* Matches regular expression case insensitive field ~* ^TeXt
& Logical AND field > 0 & field < 10
| Logical OR field > 0 | field < 10
() Expression grouping (field > 0 & field < 10) | field > 100
[] One of many field = ['one', 'two', 'three']

Note:

Operating example

Take the following query as an example:

a.x1 > 2021-12-01 & ( b >= "5" | (c > 0 & d ~ 'a + b & c = 0' & f <= -1 | a ~* "hello" )) & e != 10 | g = [1,2,"z"] & n = ""

Such this parser converts into the MongoDB query of the following form:

{"$or":[
  {"$and":[
    {"a.x1":{"$gt": "2021-12-01T00:00:00.000Z"}},
    {"$or":[
      {"b":{"$gte":"5"}},
      {"$or":[
        {"$and":[
          {"c":{"$gt":0}},
          {"d":{"$regex":"a + b & c = 0"}},
          {"f":{"$lte":-1}}
        ]},
        {"a":{"$regex":"hello","$options":"i"}}
      ]}
    ]},
    {"e":{"$ne":10}}
  ]},
  {"$and":[
    {"g":{"$in":[1,2,"z"]}},
    {"n":{"$in":["",null]}}
  ]}
]}

Parser implementation

Below is the implementation of the query parser.

const patterns = [
  /('[^']*')/,
  /("[^"]*")/,
  /\(([^()]+)\)/,
  /([^&|]+>=[^&|]+)/,
  /([^&|]+>[^&|]+)/,
  /([^&|]+<=[^&|]+)/,
  /([^&|]+<[^&|]+)/,
  /([^&|]+!=[^&|]+)/,
  /([^&|]+=[^&|]+)/,
  /([^&|]+~\*[^&|]+)/,
  /([^&|]+~[^&|]+)/,
  /([^|]*[&][^|]*)/
];

function parse (str, params = {}, opts = {}) {
  if (!opts.c) {
    opts.c = 0;
  }
  if (!opts.p) {
    do {
      opts.p = Math.random().toString(16).slice(2);
    } while (str.indexOf(opts.p) > -1);
  }
  for (let i = 0; i < patterns.length; i++) {
    const re = patterns[i];
    while (true) {
      const r = str.match(re);
      if (!r) break;
      const k = `x${++opts.c}_${opts.p}`;
      str = str.replace(r[0], k);
      const param = r[0] !== r[1] ? parse(r[1], params, opts) : r[1];
      params[k] = i < 2 ? param : param.replace(/\s/g, '');
    }
  }
  return str;
}

function compile (str, params = {}) {
  const val = params[str] || str;
  switch (true) {
  case /^'[^']*'$/.test(val): {
    const r = val.split(/^'([^']*)'$/);
    const value = r[1] || '';
    return value;
  }
  case /^"[^"]*"$/.test(val): {
    const r = val.split(/^"([^"]*)"$/);
    const value = r[1] || '';
    return value;
  }
  case /^\[[^[\]]*\]$/.test(val): {
    const arr = [];
    const r = val.slice(1, -1).split(/\s*,\s*/);
    for (let i = 0; i < r.length; i++) {
      const value = compile(r[i], params);
      arr.push(value);
    }
    return arr;
  }
  case />=/.test(val): {
    const r = val.split(/\s*>=\s*/);
    const field = compile(r[0], params);
    const value = compile(r[1], params);
    return { [field]: { $gte: value } };
  }
  case />/.test(val): {
    const r = val.split(/\s*>\s*/);
    const field = compile(r[0], params);
    const value = compile(r[1], params);
    return { [field]: { $gt: value } };
  }
  case /<=/.test(val): {
    const r = val.split(/\s*<=\s*/);
    const field = compile(r[0], params);
    const value = compile(r[1], params);
    return { [field]: { $lte: value } };
  }
  case /</.test(val): {
    const r = val.split(/\s*<\s*/);
    const field = compile(r[0], params);
    const value = compile(r[1], params);
    return { [field]: { $lt: value } };
  }
  case /!=/.test(val): {
    const r = val.split(/\s*!=\s*/);
    const field = compile(r[0], params);
    let value = compile(r[1], params);
    if (!value) value = ['', null];
    return { [field]: Array.isArray(value) ? { $nin: value } : { $ne: value } };
  }
  case /=/.test(val): {
    const r = val.split(/\s*=\s*/);
    const field = compile(r[0], params);
    let value = compile(r[1], params);
    if (!value) value = ['', null];
    return { [field]: Array.isArray(value) ? { $in: value } : value };
  }
  case /~\*/.test(val): {
    const r = val.split(/\s*~\*\s*/);
    const field = compile(r[0], params);
    const value = compile(r[1], params);
    return { [field]: { $regex: value, $options: 'i' } };
  }
  case /~/.test(val): {
    const r = val.split(/\s*~\s*/);
    const field = compile(r[0], params);
    const value = compile(r[1], params);
    return { [field]: { $regex: value } };
  }
  case /&/.test(val): {
    const arr = [];
    const r = val.split(/\s*&\s*/);
    for (let i = 0; i < r.length; i++) {
      const value = compile(r[i], params);
      arr.push(value);
    }
    return { $and: arr };
  }
  case /\|/.test(val): {
    const arr = [];
    const r = val.split(/\s*\|\s*/);
    for (let i = 0; i < r.length; i++) {
      const value = compile(r[i], params);
      arr.push(value);
    }
    return { $or: arr };
  }
  default: {
    let value = val;
    while (params[value]) {
      value = compile(params[value], params);
    }
    if (!isNaN(value)) return Number(value);
    const date = Date.parse(value);
    if (!isNaN(date)) return new Date(date);
    return value || null;
  }
  }
}

function convert (query) {
  const params = {};
  const p = parse(query, params);
  return compile(p, params);
}

Usage example:

const query = convert('x > 10');
console.log(query);
// {"x":{"$gt":10}}

Comments