实现python语法转mongo查询

有时候我发现我好像比较喜欢看电脑干活,电脑在忙等于我在忙,电脑很累然而我不累

为了满足自己看电脑干活的爱好,我打算把平时可能会遇到的写mongo查询语句的任务简单化,大伙都知道mongo的查询语法是类似json格式的,写起来不如SQL那样畅快淋漓,于是我就想把这个查询的活完全交给电脑去完成,但是电脑又不是什么高情商,我在想什么它怎么可能知道?于是可悲的代沟让我的语言表达倍感压力——mongo语法的语言表达

为了缩小我和电脑之间的代沟,我打算设计一个新的语法翻译器,用新潮的类python语法构造mongo语法,当然这个翻译器是因地制宜的,它仅支持很简单的几个操作(但是却可以大大节省我的键盘寿命,特别是“{”、“}”、“:”这几个键的寿命)

就像设计一个全新的编程语言一样,接下来我们先设计关键字和运算符:

关键字/运算符 描述 用法示例
title 网页标题 -
header http响应头 -
body html响应 -
or 或运算符 ‘nginxwebui’ in body or title is ‘nginxwebui’
and 且运算符 ‘nginxwebui’ in body and title is ‘nginxwebui’
in 包含运算符 ‘nginxwebui’ in body
is 相等运算符 title is ‘nginxwebui’
not 非运算符 not title is ‘nginxwebui’

设计好了这一套优美的英国话之后,可能大伙会对not运算符的优美性产生质疑

但是如果设计成title is not xxx这种,我的工作量会大一倍,这个跟python的解析有关,虽然存在投机取巧的可能,但是我连投机取巧都懒得做,具体什么情况咱后面再说

大伙可能都记得lex和yacc的痛苦,但是python就非常甜,因为这套优美的查询语句基本就是照抄python的关键字的,所以直接使用python的ast模块就能直接一把梭

下面给个例子:

import ast

test = "'a' in title and ('b' in header or body is 'c')"
tree = ast.parse(test)
print(ast.dump(tree, indent=2))

运行结果如下:

Module(
  body=[
    Expr(
      value=BoolOp(
        op=And(),
        values=[
          Compare(
            left=Constant(value='a'),
            ops=[
              In()],
            comparators=[
              Name(id='title', ctx=Load())]),
          BoolOp(
            op=Or(),
            values=[
              Compare(
                left=Constant(value='b'),
                ops=[
                  In()],
                comparators=[
                  Name(id='header', ctx=Load())]),
              Compare(
                left=Name(id='body', ctx=Load()),
                ops=[
                  Is()],
                comparators=[
                  Constant(value='c')])
])]))],
  type_ignores=[])

直接帮我们生成了语法树,接下来要做的事情就是遍历这个语法树,一边遍历一边组装mongo查询语句

因为要实现一边遍历一边组装mongo语句,所以要制造一个自动机类似物,不过先不急,我们先来看看mongo语法是怎样的,针对每部分进行实现即可

mongo语法类似json,结构化程度很高,因此自动构造也比较容易

and运算符实现

mongodb有一个$and运算符,语法规则如下:

{ $and: [ { <expression1> }, { <expression2> }, ... , { <expressionN> } ] }

这个效果就是mongo会输出满足所有expression的数据

这个and运算符实际上mongo的设计和python语法树的表示方式是心有灵犀的,我们可以运行代码看看:

import ast

test = "'a' in title and 'b' in header and body is 'c'"
tree = ast.parse(test)
print(ast.dump(tree, indent=2))

结果:

Module(
  body=[
    Expr(
      value=BoolOp(
        op=And(),
        values=[
          Compare(
            left=Constant(value='a'),
            ops=[
              In()],
            comparators=[
              Name(id='title', ctx=Load())]),
          Compare(
            left=Constant(value='b'),
            ops=[
              In()],
            comparators=[
              Name(id='header', ctx=Load())]),
          Compare(
            left=Name(id='body', ctx=Load()),
            ops=[
              Is()],
            comparators=[
              Constant(value='c')])]))],
  type_ignores=[])

可能上面的运行结果看起来没那么直观,我简化一下给大家看看:

Module(
  body=[
    Expr(
      value=BoolOp(
        op=And(),
        values=[
          Compare(...),
          Compare(...),
          Compare(...)],
  type_ignores=[])

看values字段,是不是跟mongo的语法一模一样

这样就好设计了,自动机在遍历到BoolOp的时候,检查op的值是否是And,如果是,就遍历values然后把Compare表达式转换出来的结果填到mongo语法的方括号里

具体的代码我拉在文末了,不然东一坨西一坨的影响环境

or运算符

和and一模一样,没区别

is运算符

为什么我不先介绍in运算符的原理呢?因为in运算符是is运算符的一个特殊情况,所以一会再说

mongo做完整匹配的语法差不多是这样的:

db.users.find({ age: 30 })

冒号左边是字段名,右边是匹配的值

我们的语法支持title is ‘a’,也支持’a’ is title,这根据用户的自身情况,如果倒装是喜欢用户的,那山东人用户可能是

那这个要怎么做呢?很简单,先看看语法树:

import ast

test = "'a' is title"
test2 = "title is 'a'"
tree = ast.parse(test)
tree2 = ast.parse(test2)
print(ast.dump(tree))
print(ast.dump(tree2))
Module(body=[Expr(value=Compare(left=Constant(value='a'), ops=[Is()], comparators=[Name(id='title', ctx=Load())]))], type_ignores=[])
Module(body=[Expr(value=Compare(left=Name(id='title', ctx=Load()), ops=[Is()], comparators=[Constant(value='a')]))], type_ignores=[])

可以看到Constant和Name也是反过来的,但是我们在转换成mongo语法的时候还是一定要保持左边字段名右边值的格式

这怎么办呢?实际上我们可以设计一个字典,这个字典的键有两个,一个是Constant,另一个是Name,遍历语法树时,如果遍历到is语句,那就把Constant和Name都存入字典对应的位置,组装mongo语句的时候再拿出来就好

in运算符

刚才说in运算符是is的一个特例,现在就来解释

我们先来看看in运算符对应mongo里面的哪个语法

db.users.find({ "name" : { $regex : "web"} })

差不多就是上面这种,mongo支持正则表达式,作为语法翻译器,我们不需要处理用户输入的常量数据,所以我们也是支持在in表达式里面塞正则表达式

其实in的语法树和is的是一样的,除了那个ops字段之外,都一样,但是in运算符有一个顺序问题,我只想支持'a' in title,而不想支持title in 'a',大伙都学过英语,都知道这两句话的差异

所以在写代码的时候做了代码层面的限制,语法树节点left的类型必须是Constant,而comparators[0]的类型必须是Name,其它跟is运算符一样

not运算符

not运算符是比较特殊的,之前也提到我只支持了not [expr]这样的格式,而不支持[expr] is not [expr]这样的,因为python语法树给他俩打的标签不一样:

import ast

test = "not 'a' in title"
test2 = "'a' not in title"
tree = ast.parse(test)
tree2 = ast.parse(test2)
print(ast.dump(tree))
print(ast.dump(tree2))
Module(body=[Expr(value=UnaryOp(op=Not(), operand=Compare(left=Constant(value='a'), ops=[In()], comparators=[Name(id='title', ctx=Load())])))], type_ignores=[])
Module(body=[Expr(value=Compare(left=Constant(value='a'), ops=[NotIn()], comparators=[Name(id='title', ctx=Load())]))], type_ignores=[])

因为python是把not in算成单独一种运算符,因此我如果要解析这个语法,那我就必须实现in、not in、is、is not四个语法的解析代码,但是如果我把not单独拎出来,我只需实现not、is、in三个语法的解析代码;虽然可以做一个魔改,把not in语法转换成not ‘xxx’ in title这种类型,但是感觉还是麻烦,主要是容易出现不确定的情况,怕出现不稳定的问题,所以就一刀切了

接下来想办法把not的语法树转换成mongo语法,mongo的not语法如下:

{ <field>: { $not: { <operator-expression> } } }

可以看到它这个的mongo数据库字段名是写在not运算符外面的,但是我们的语法规定not写在外面,刚刚避免了not in和not is的麻烦,但是却迎来了语法顺序的问题

但是这个也不是无法解决的,我们可以回顾刚才见过的所有mongo语法,在字典格式的mongo语法中,字典的键总是mongo字段名,而字典的值总是用户输入

因此只需将语法树operand字段的语法翻译结果的键提取到外面来就好了,直接重新整一遍格式就ok

代码

语法树遍历的代码如下

import ast


def dfs(node):
    if not node:
        return {}
    if node.__class__ == ast.BoolOp:
        if node.op.__class__ == ast.And:
            res = {'$and': []}
            for i in node.values:
                con = dfs(i)
                res['$and'].append(con)
            return res
        if node.op.__class__ == ast.Or:
            res = {'$or': []}
            for i in node.values:
                con = dfs(i)
                res['$or'].append(con)
            return res
    if node.__class__ == ast.Compare:
        op = node.ops[0]
        if op.__class__ == ast.Is:
            l = dfs(node.left)
            r = dfs(node.comparators[0])
            flags = {node.left.__class__: l, node.comparators[0].__class__: r}
            if ast.Name in flags and ast.Constant in flags:
                return {flags[ast.Name]: flags[ast.Constant]}
            return {}
        if op.__class__ == ast.In:
            if node.left.__class__ == ast.Constant and node.comparators[0].__class__ == ast.Name:
                l = dfs(node.left)
                r = dfs(node.comparators[0])
                return {r: {'$regex': l}}
            return {}
    if node.__class__ == ast.UnaryOp:
        if node.op.__class__ == ast.Not:
            not_expr = list(dfs(node.operand).items())[0]
            res = {not_expr[0]: {'$not': not_expr[1]}}
            return res
    if node.__class__ == ast.Constant:
        return node.value
    if node.__class__ == ast.Name:
        return node.id

外部调用:

from dfs import dfs
import ast

text = "'/imc/login.jsf' in body and '/imc/javax.faces.resource/images/login_help.png.jsf?ln=primefaces-imc-new-webui' in body"
tree = ast.parse(text)
#print(ast.dump(tree))
print(dfs(tree.body[0].value))

运行结果:

{'$and': [{'body': {'$regex': '/imc/login.jsf'}}, {'body': {'$regex': '/imc/javax.faces.resource/images/login_help.png.jsf?ln=primefaces-imc-new-webui'}}]}

感觉应该没问题,有问题再改