用新的Traverser API遍历jOOQ表达式树

578 阅读7分钟

从jOOQ 3.16开始,我们在开放我们的内部查询对象模型(QOM)作为公共API方面投入了很多。这主要对那些使用jOOQ的解析器并希望访问解析后的表达式树的人有用,或者对SQL进行转换,例如在jOOQ中实现行级安全

但偶尔,即使在普通的jOOQ动态SQL使用中,访问表达式树也是有用的:

请注意,从jOOQ 3.16开始,所有这些新的API都是实验性的,因此在未来会有不兼容的变化。使用它的风险由你自己承担。

查询对象模型(QOM)的API

第一个改进是为查询对象模型本身提供一个API。一个新的类型叫做 [org.jooq.impl.QOM](https://www.jooq.org/javadoc/latest/org.jooq/org/jooq/impl/QOM.html)的新类型包含了所有这些新的公共API,而实现仍然是相同的旧类型,在org.jooq.impl 包中,但具有包-私有的可见性。

当你创建一个SUBSTRING() 函数调用表达式时,你会得到一个Field<String> 表达式,它实现了 [QOM.Substring](https://www.jooq.org/javadoc/latest/org.jooq/org/jooq/impl/QOM.Substring.html).在这个类型上,你可以调用各种访问器方法,这些方法总是以"$" 符号开头,以访问函数参数:

// Create an expression using the DSL API:
Field<String> field = substring(BOOK.TITLE, 2, 4);
// Access the expression's internals using the model API
if (field instanceof QOM.Substring substring) {
    Field<String> string = substring.$string();
    Field<? extends Number> startingPosition = 
        substring.$startingPosition();
    Field<? extends Number> length = substring.$length();
}

一些可能会被改变的东西

1.目前还不清楚DSL方法substring() 是返回QOM类型Substring ,还是DSL类型Field 。两者都有优点和缺点,尽管对于DSL用户来说,有一点倾向于让QOM类型不被看到。

2.2."$" 前缀用于明确区分DSL API(无前缀)和QOM API("$" 前缀),因为现在这两个API共享类型层次,用户应该清楚他们是在构建jOOQ对象用于DSL,还是在操作表达树的对象。

对于每个访问器,也有一个 "突变器",这个方法产生一个包含突变值的新QOM类型。所有的QOM类型都是不可改变的,所以原始的Substring实例不会受到像这样的修改的影响:

Substring substring1 = (Substring) substring(BOOK.TITLE, 2, 4);
Substring substring2 = substring1
    .$startingPosition(val(3))
    .$length(val(5));

assertEquals(substring2, substring(BOOK.TITLE, 3, 5));

上述所有的API、访问器和突变器都将适用于所有的jOOQ版本,包括jOOQ开源版。

表达式树的遍历

当你想遍历表达式树时,真正的乐趣就开始了,例如,寻找对象的存在,收集对象,等等。为此,我们在商业版jOOQ中引入了新的 [Traverser](https://www.jooq.org/javadoc/latest/org.jooq/org/jooq/Traverser.html)为此,我们在商业版的jOOQ中引入了新的API。

Traverser的工作方式与JDKCollector 相当类似,它可以遍历一个Stream,并将元素收集到一些数据结构中。但是Traverser在树上操作,因此有一些额外的功能:

  • 它可以在访问一个树状元素(以及它们的子树!)之前和之后接收事件。
  • 它可以为每个树元素决定是否应该向子树递归遍历。这是非常有用的,例如,如果你不关心任何种类的子查询的遍历。
  • 它可以决定是否提前终止遍历,例如,当找到第一个对象时。我不知道JDKCollector是否提供这样的短路方法,尽管我认为这在那里也很有用。(用Spliterator 也可以,但那要麻烦得多)
  • 它不具备并行能力。并行性已经是流的一个可选的特性,但是对于树,我们还没有发现支持并行性的好处,这使得遍历更加简单。

一个简单的遍历例子是计算一个表达式中的所有QueryPart对象,像这样:

// Contains 7 query parts
System.out.println(
    T_BOOK.ID.eq(1).or(T_BOOK.ID.eq(2))
        .$traverse(() -> 0, (c, p) -> c + 1)
);

这个简单的方便方法提供了一个辅助数据结构(这里是一个int),以及一个将每个查询部分累积到该数据结构的函数。其结果是数据结构(int)本身。

为什么它要计算7呢?因为它遍历了以下的树:

1: T_BOOK.ID.eq(1).or(T_BOOK.ID.eq(2))
2: T_BOOK.ID.eq(1)
3: T_BOOK.ID
4: 1
5: T_BOOK.ID.eq(2)
6: T_BOOK.ID
7: 2

或者从视觉上看:

OR
├── EQ
│   ├── T_BOOK.ID
│   └── 1
└── EQ
    ├── T_BOOK.ID
    └── 2

如果你想简单地收集每个单独的QueryPart ,就这样做:

System.out.println(
    T_BOOK.ID.eq(1).or(T_BOOK.ID.eq(2))
        .$traverse(
            () -> new ArrayList<QueryPart>(),
            (list, p) -> {
                list.add(p);
                return list;
            }
        )
);

这样做的输出结果是(不是本地格式):

[  ("PUBLIC"."T_BOOK"."ID" = 1 or "PUBLIC"."T_BOOK"."ID" = 2),  "PUBLIC"."T_BOOK"."ID" = 1,  "PUBLIC"."T_BOOK"."ID",  1,  "PUBLIC"."T_BOOK"."ID" = 2,  "PUBLIC"."T_BOOK"."ID",  2]

这个例子表明,该树是以深度优先的方式进行遍历的。

但是你不需要自己写这样简单的Traversers。任何JDKCollector都可以作为Traverser ,所以上面的两个例子可以这样改写:

System.out.println(
    T_BOOK.ID.eq(1).or(T_BOOK.ID.eq(2))
        .$traverse(Traversers.collecting(Collectors.counting()))
);

System.out.println(
    T_BOOK.ID.eq(1).or(T_BOOK.ID.eq(2))
        .$traverse(Traversers.collecting(Collectors.toList()))
);

想收集一个查询的所有涉及的表?没问题!

System.out.println(
    T_BOOK.ID.eq(1).and(T_AUTHOR.ID.eq(3))
        .$traverse(Traversers.collecting(
            Collectors.mapping(
                p -> p instanceof TableField<?, ?> tf 
                    ? tf.getTable() 
                    : p,
                Collectors.filtering(
                    p -> p instanceof Table,
                    Collectors.toSet()
                )
            )
        ))
);

这可以理解为:

  • 将所有的TableField引用映射到它们的Table容器中
  • 过滤掉所有Table 的引用
  • 将它们收集到一个不同的Set的表。

生成:

["PUBLIC"."T_BOOK", "PUBLIC"."T_AUTHOR"]

表达式树的转换

如果你想用一个表达式替换另一个表达式怎么办?有各种用例,我们最终会在商业的jOOQ版本中支持这些用例,但你也可以使用这个API推出自己的扩展。

这种转换的一个非常简单的例子是去除多余的布尔否定:

// Contains redundant operators
Condition c = not(not(BOOK.ID.eq(1)));
System.out.println(c.$replace(q ->
    q instanceof Not n1 && n1.$arg1() instanceof Not n2
        ? n2.$arg1()
        : q
));

尽管明确写了not(not(x)) ,但输出只是x ,或者具体地说:

"BOOK"."ID" = 1

这种转换的实际应用案例包括。

常见模式的优化和替换

有几个理由来规范和改进SQL字符串的常见模式:

从jOOQ 3.17开始,我们将提供很多这样的转换,开箱即用。你可以出于不同的原因将它们打开:

  • 普遍优化你的SQL输出
  • 检测你的查询中的问题,无论是通过jOOQ API实现的,还是通过解析器拦截的--经验法则是,如果这个模式识别功能找到了要转换的东西,那么你自己的SQL查询就应该得到改进。可以这么说,是一个inter。

从jOOQ 3.17开始的开箱即用功能:https://github.com/jOOQ/jOOQ/issues/7284

行级安全或共享模式多租户

你今天已经可以使用jOOQ的VisitListenerSPI实现客户端行级安全,这是这些基于新查询对象模型的SQL转换功能的前身。但有了新的替换API,对于用户以及我们来说,支持开箱即用的行级安全功能将变得更加简单。简而言之,想象一下,每次你查询一个受限制的表,如ACCOUNT:

SELECT * FROM account

你想要的是确保用户只能访问他们自己的账户,也就是说,这应该被修补到查询中,对开发者来说是透明的:

SELECT * FROM account WHERE account_id IN (:userAccountList)

一个简单的算法是这样写的:

QueryPart q = select(ACCOUNT.ID).from(ACCOUNT);
System.out.println(
    q.$replace(p -> {
        if (p instanceof Select<?> s) {

            // Check if the query contains the relevant table(s) in
            // the FROM clause
            if (s.$from().$traverse(Traversers.containing(ACCOUNT)) && (

                // In the absence of a WHERE clause
                s.$where() == null ||

                // Or, if we haven't already added our IN list
                !s.$where().$traverse(Traversers.containing(
                    x -> x instanceof InList<?> i 
                        && ACCOUNT.ID.equals(i.$arg1())
                ))
            )) {

                // Append a predicate to the query
                // Imagine this reading some context info
                return s.$where(DSL.and(s.$where(), 
                    ACCOUNT.ID.in(1, 2, 3)));
            }
        }
        return p;
    })
);

上面的结果将是:

select "PUBLIC"."ACCOUNT"."ID"
from "PUBLIC"."ACCOUNT"
where "PUBLIC"."ACCOUNT"."ID" in (
  1, 2, 3
)

请注意,输入的SQL查询并不包含任何这样的谓词。很明显,这还远远不够完整。它没有正确地处理外部连接(其中谓词可能必须进入ON子句),以及其他注意事项。请继续关注该领域的更多信息。

还没有发布目标的开箱即用功能:
github.com/jOOQ/jOOQ/i…

更多用例

在上述功能的基础上,我们还计划支持更多的使用情况,即开箱即用。这些包括:

  • 软删除,将DELETE 语句转化为 "等价的 "UPDATE .. SET deleted = true语句,以及将SELECT语句转化为 "等价的 "SELECT .. WHERE NOT deleted ,见github.com/jOOQ/jOOQ/i…
  • 审计列支持,只要任何DML查询触及 "审计 "字段,如CREATED_AT,CREATED_BY,MODIFIED_AT,MODIFIED_BY,我们就会更新它们,见github.com/jOOQ/jOOQ/i…

用例敏捷性

请记住,像大多数其他jOOQ功能一样,这个功能也是完全不受用例影响的。你是否在使用jOOQ并不重要:

  • 作为一个内部DSL来创建动态(或 "静态")SQL查询
  • 作为一个解析器,在SQL方言之间进行翻译
  • 作为一个分析器来丰富你基于传统ORM的应用程序
  • 作为一个诊断工具,对你基于传统ORM的应用程序进行检查

不管是什么用途,你都可以使用这个API来分析和转换你的SQL查询。

限制(截止到jOOQ 3.16)

如前所述,到目前为止,这是一个实验性功能,还没有真正为生产做好准备。目前的设计和实现有相当多的已知限制。请考虑这个问题的开放性问题。

github.com/jOOQ/jOOQ/i…

到目前为止,最重要的限制包括:

  • 只支持SELECT,没有其他语句
  • 遍历还没有进入JOIN 树或UNION /INTERSECT /EXCEPT 子查询

还有更多的限制,但这些是最重要的限制。因此,请继续关注这一领域的更多令人兴奋的发展,在下一个jOOQ版本中很快就会出现。