docs: function normFilter(model, filter, fieldName) {
    if (!filter) return null;
  
    let result = {},
      fields = model.def.fields;
    for (const key in filter) {
      if (key === '$and' || key === '$or') { // {$and:[]}, {$or: []}
        const subfilters = [];
        for (const j in filter[key]) {
          const sub = normFilter(model, filter[key][j]);
          if (!sub) continue;
          subfilters.push(sub);
        }
        if (subfilters.length > 0) result[key] = subfilters;
        continue;
      }
      if (key.charAt(0) === '$') { // key is an operator, {op: value}
        if (!fieldName) {
          throw new QueryError(model.name, 'no field for operator %s in filter', key);
        }
        if (!fields[fieldName]) {
          throw new QueryError(model.name, 'nonexistent field %s in filter', fieldName);
        }
        result[key] = fields[fieldName].normalize(filter[key], { query: true });
        continue;
      }
  
      // key is a field, {field: value}, {field: {op: value, ...}}
      fieldName = key;
      const field = fields[key];
      if (!field) throw new QueryError(model.name, 'nonexistent field %s in filter', key);
      if (field.props.filterRedirect) {
        const proxyFields = field.props.filterRedirect.replace(/\s+/g, '').split(',');
        const proxyFilter = { $or: [] };
        for (const pi in proxyFields) {
          const proxyFieldName = proxyFields[pi];
          const fieldProxyFilter = {};
          fieldProxyFilter[proxyFieldName] = filter[key];
          proxyFilter.$or.push(fieldProxyFilter);
        }
        Object.assign(result, normFilter(model, proxyFilter));
        continue;
      }
  
      // {field: {op: value, ...}}
      if (util.isPlainObject(filter[key])) {
        result[fieldName] = normFilter(model, filter[key], fieldName);
        continue;
      }
  
      // {field: value}, value is not string
      let value = filter[key];
      if (typeof value !== 'string') { // array, date, int, float, bool, etc
        result[fieldName] = field.normalize(value, { query: true });
        continue;
      }
  
      // {field: value}, value is a plain string
      value = value.trim();
      if (!(value.substring(0, 1) === '{' && value.substr(-1) === '}')) {
        result[fieldName] = field.normalize(value, { query: true });
        continue;
      }
  
      // {field: value}, value is string and may contain a json object which is a query filter too
      const jsonExpr = null;
      try {
        eval('jsonExpr = '+value);//eslint-disable-line
      } catch (e) {}// eslint-disable-line no-empty
      if (!jsonExpr) { // parse fails, treat as plain string
        result[fieldName] = field.normalize(value, { query: true });
      } else {
        const jsonFilter = {};
        for (const jk in jsonExpr) {
          if (jk.charAt(0) === '$') { // expand {op:val} to {field:{op: val}}
            jsonFilter[fieldName] = {};
            jsonFilter[fieldName][jk] = jsonExpr[jk];
          } else { // {field: value}
            jsonFilter[jk] = jsonExpr[jk];
          }
        }
        result = Object.assign(result, normFilter(model, jsonFilter, fieldName));
      }
    }
  
    if (util.isEmptyObject(result)) return null;
    return result;
  }