在jOOQ内部推高SQL片段的详细指南

在过去的13年中,jOOQ已经积累了一些内部功能,而你,即用户,并没有接触到这些功能。一个非常有趣的功能是,任何任意的jOOQ表达式树元素都可以将一个SQL片段推到更高层次。

它是如何工作的?

jOOQ表达式树模型

当你写一个jOOQ查询时,你实际上是在创建一个你的SQL语句的表达式树,它看起来就像实际的SQL语法,比如说:

ctx.select(BOOK.ID, BOOK.TITLE)
   .from(BOOK)
   .where(BOOK.AUTHOR_ID.eq(1))
   .fetch();

在jOOQ中,这无非是这种形式的表达式树

                     Select (Statement)
                             |
          +------------------+-----------------+
          |                  |                 |
        Select             From              Where
          |                  |                 |
    +-----+------+           |                 |
    |            |           |                 |
TableField  TableField     Table        CompareCondition
  |     |     |     |        |           |     |       |
Table Field Table Field    BOOK    TableField  EQ     Val
  |     |     |     |                |     |           |
BOOK    ID   BOOK TITLE            Table Field         1
                                     |     |
                                    BOOK AUTHOR_ID

当生成SQL时,jOOQ主要是先遍历表达树(开个玩笑。主要是深度第一,但在往下走一级之前,往往会考虑到同一层的一些子元素),将每个元素收集到一个StringBuilder ,以达到预期的形式:

SELECT book.id, book.title
FROM book
WHERE book.author_id = 1

这些所谓的QueryPart 表达式树元素中的每一个都可以自己决定如何呈现其SQL。例如,CompareCondition 将大致生成这个序列:

<lhs> <operator> <rhs>

...进一步将SQL的生成委托给它的子元素,不管它们是什么。一个TableField 将决定是否完全/部分/或根本不限定其Field 的引用,等等,例如,基于模式映射(多租户)功能

如果你使用的是函数,比如说 [Substring](https://www.jooq.org/doc/current/manual/sql-building/column-expressions/string-functions/substring-function/),该函数可以自行决定应该如何生成其SQL。从手册中可以看出,这些都是一样的:

-- ACCESS
mid('hello world', 7)

-- ASE, SQLDATAWAREHOUSE, SQLSERVER
substring('hello world', 7, 2147483647)

-- AURORA_MYSQL, AURORA_POSTGRES, COCKROACHDB, CUBRID, H2, 
-- HANA, HSQLDB, IGNITE, MARIADB, MEMSQL, MYSQL, POSTGRES, REDSHIFT, 
-- SNOWFLAKE, SYBASE, VERTICA
substring('hello world', 7)

-- DB2, DERBY, INFORMIX, ORACLE, SQLITE
substr('hello world', 7)

-- FIREBIRD, TERADATA
substring('hello world' FROM 7)

这样一来,一个jOOQ表达式树可以模拟任何方言中的任何语法。

但如果仿真不是本地的呢?

非本地仿真

有时,一个QueryPart需要假设有一个非本地的语法元素存在才能工作。最近的一个案例是github.com/jOOQ/jOOQ/i…

在jOOQ中编写这个程序逻辑时:

for_(i).in(1, 10).loop(
    insertInto(t).columns(a).values(i)
)

当然,你不会这样做。你会写一个批量插入语句来代替,只用SQL来解决这个问题!但你有你的理由,对吗?

那么,在一些方言中,索引的FOR循环可能要用一个等价的WHILE语句来模拟。因此,而不是这种直接的程序性SQL生成,例如我们可能在Oracle中得到的:

FOR i IN 1 .. 10 LOOP
  INSERT INTO t (a) VALUES (i);
END LOOP;

...我们在MySQL中生成这个,或多或少:

DECLARE i INT DEFAULT 1;
WHILE i <= 10 DO
  INSERT INTO t (a) VALUES (i);
  SET i = i + 1;
END WHILE;

这仍然可以完全在本地完成,如前所示。有一个FOR 表达式树元素,可以本地生成DECLAREWHILE 查询部分,而不是。但如果本地变量是不可能的呢?如果没有块范围,比如在Firebird中呢?

在Firebird中,你所有的局部变量都必须在顶层声明部分进行声明。如果我们在匿名块中运行上述内容,正确生成的程序性SQL将是这样的:

EXECUTE BLOCK AS
  DECLARE i INT;
BEGIN
  :i = 1; -- The loop variable must still be initialised locally
  WHILE (:i <= 10) DO BEGIN
    INSERT INTO t (a) VALUES (i);
    :i = :i + 1;
  END
END

当我们进一步将循环嵌套在程序性控制流元素中时,情况仍然如此,例如:

for_(i).in(1, 10).loop(
    for_(j).in(1, 10).loop(
        insertInto(t).columns(a, b).values(i, j)
    )
)

当然,你还是不会这样做。你会写一个来自卡特尔产品的批量插入语句,而只用SQL来解决这个问题!但可惜的是,让我们保持这个例子的简单性

我们现在有嵌套的变量声明,这在MySQL中仍然很好用:

DECLARE i INT DEFAULT 1;
WHILE i <= 10 DO 
  BEGIN
    DECLARE j INT DEFAULT 1;
    WHILE j <= 10 DO
      INSERT INTO t (a, b) VALUES (i, j);
      SET j = j + 1;
    END WHILE;
  END
  SET i = i + 1;
END WHILE;

但在Firebird中,声明都要被推到顶部:

EXECUTE BLOCK AS
  DECLARE i INT;
  DECLARE j INT;
BEGIN
  :i = 1; -- The loop variable must still be initialised locally
  WHILE (:i <= 10) DO BEGIN
    :j = 1; -- The loop variable must still be initialised locally
    WHILE (:j <= 10) DO BEGIN
      INSERT INTO t (a, b) VALUES (i, j);
      :j = :j + 1;
    END
    :i = :i + 1;
  END
END

这还不能处理所有的边缘情况(例如,一些方言允许 "隐藏 "局部变量,如PL/SQL),但它对简单的过程、函数触发器已经有了很大的帮助,所有这些都将在jOOQ 3.15中开始支持。

它是如何工作的?

替代方案1:表达式树转换

有许多方法可以使这样的表达式树转换发挥作用。从长远来看,我们将重新设计我们的内部表达式树模型,并将其作为公共API提供给那些希望将jOOQ解析器和表达式树转换作为一个独立产品使用的人。在某种程度上,这已经可以使用 VisitListener SPI,正如这篇关于行级安全的文章中所显示的那样,但目前的实现是复杂的。

另外,表达式树需要非本地转换的情况比较少(到目前为止)。这意味着每次都急切地试图寻找可能的候选者可能是多余的。

替代方案 2:懒惰的表达式树转换

我们可以 "懒惰地 "转换表达式树,也就是说,仍然假设它是不必要的,当它是不必要的时候,抛出一个异常并重新开始。我们实际上是这样做的,jOOQ中的 "模式 "被称为ControlFlowSignal它主要用于解决不同方言中每条语句的最大绑定参数数限制。也就是说,我们只是计算绑定值,如果在SQL Server中超过2000个(SQL Server支持2100个,但jtds只支持2000个),那么我们就在静态语句中使用内联值从头开始重新生成SQL。

和jOOQ一样,你可以将这些限制重新配置为你自己的值

ROWNUM 另一种情况是当你在迁移到Oracle旧的 ROWNUMLIMIT 的过滤时,忘记了打开 的转换。如果每次都急切地搜索ROWNUM 的实例,那就太傻了。相反,我们只是在遇到一个SQL查询时重新生成整个SQL查询,而且是在你不使用Oracle时。

这里的假设是,这些事情很少 发生,如果发生了,你也没有想到,你也不希望查询在生产中失败。一个可能已经很慢的查询需要jOOQ多花一点时间来生成,这对查询来说是值得付出的代价,因为它仍然可以正常工作。™

替代方案3:对生成的SQL字符串进行修补

现在,这就是我们实际要做的事情。

最好是假设几乎所有的SQL转换都是本地的(就像Substring 中的例子一样),并对SQL进行修补,以防它们不是。最后,我们只是在生成SQL字符串而已既然如此,为什么不引入一个基础设施,我们可以把特殊的标记放到特殊的文本区域,然后用替代的SQL内容替换该区域。

如果没有#11366的修复,生成的代码可能是这样的:

EXECUTE BLOCK AS
  -- Special marker here
BEGIN 
  -- Entering scope
  DECLARE i INT DEFAULT 1;
  WHILE (:i <= 10) DO BEGIN
    DECLARE j INT DEFAULT 1;
    WHILE (:j <= 10) DO BEGIN
      INSERT INTO t (a, b) VALUES (i, j);
      :j = :j + 1;
    END
    :i = :i + 1;
  END
  -- Exiting scope
END

这在Firebird中不起作用,所以我们应用了这个修正。注意有一个便宜的、特殊的标记,它是由匿名块的SQL生成的,但也适用于过程、函数和触发器,比如说:

CREATE FUNCTION func1()
RETURNS INTEGER
AS 
  -- Special marker here
BEGIN
  -- Entering scope
  RETURN 1;
  -- Exiting scope
END

现在,只要org.jooq.impl.DeclarationImpl 查询部分在本地生成它的SQL,那么,就不会生成类似的东西:

DECLARE i INT DEFAULT 1;
DECLARE j INT;

我们生成(本地)的:

:i = 1;
-- j isn't initialised here, so nothing needs to be done locally

同时,我们将org.jooq.impl.DeclarationImpl 推送到整个作用域可见的某个上下文变量中(见 "进入作用域 "和 "退出作用域 "注释)。

一旦我们退出作用域,我们必须渲染所有收集到的声明,这一次没有默认值,例如:

DECLARE i INT;
DECLARE j INT;

......然后在标记所在的位置上插入那个渲染的SQL。所有后续的标记,如果有的话,当然是通过文本长度的差异来移位的。

在jOOQ中的应用

目前在jOOQ内有几次使用这个方法:

  • 模仿调用Oracle PL/SQL函数,参数/返回值为BOOLEAN 。我们给生成的SQL打补丁,产生一个合成的WITH 子句,并有一些BOOLEANNUMBER 的翻译逻辑。从Oracle 12c开始,Oracle支持在WITH中嵌入PL/SQL,这是个很好的功能
  • 整个隐式 JOIN 功能都是这样实现的!标记为FROM 子句中的每个表划界(例如:FROM BOOK ),如果在查询中遇到源于任何这样的标记表的路径(例如:SELECT BOOK.AUTHOR.FIRST_NAME ),那么:1)不生成路径,而是使用路径的合成别名来限定列,2)不生成FROM BOOK 表,而是生成一个合成的LEFT JOININNER JOIN ,连接所有必要的对一关系。下面将显示一个例子。
  • 上述Firebird(也可能是T-SQL,让我们看看)程序性局部变量范围的修正就是这样实现的。
  • 少数需要在完整语句中预置SQL的仿真,比如CREATE OR REPLACE PROCEDURE x 仿真,将DROP PROCEDURE x 预置到CREATE PROCEDURE x ,也是以类似的方式工作。这些类型的排放是 "特殊 "的,因为它们在语句批中增加了另一条语句。这意味着我们在从JDBC调用批处理时,也要注意跳过任何可能产生的结果集或更新计数。

未来的应用可能包括:

  • 更多的顶层CTE,这对各种模拟来说是相当方便的

一个隐式连接的例子

SELECT
  book.author.first_name,
  book.author.last_name
FROM book -- Special marker around book

上述SQL在任何方言中都不起作用,它只是针对jOOQ。我们根据路径的哈希代码,为每个唯一的路径生成一个别名,所以查询可能看起来像这样:

SELECT
  -- The path isn't generated, but an alias for it is
  alias_xyz.first_name,
  alias_xyz.last_name
FROM (
  -- The marked "book" table is replaced by a join tree
  book 
    LEFT JOIN author AS alias_xyz 
    ON book.author_id = author.id
)

我们只需在生成的SQL字符串中用(book LEFT JOIN ...) 替换book 。由于我们的基础设施能够定义作用域并为每个作用域注册上下文和变量,这对任意级别的嵌套都有效。我们总是可以为每个路径表达式确定适当的book 识别器,甚至像这样的事情:

SELECT
  book.author.first_name,
  book.author.last_name,

  -- Different book tables here, because the nested scope 
  -- hides the "book" identifier from the outer scope
  (SELECT count(*) FROM book),
  (SELECT count(DISTINCT book.author.first_name) FROM book),

  -- Outer book, again
  (SELECT book.author.first_name)
FROM 
  book

上述被模仿的情况是这样的,通过相同的连接图来修补book 的两个标记的出现:

SELECT
  alias_xyz.first_name,
  alias_xyz.last_name,

  -- No patching done to this book
  (SELECT count(*) FROM book),

  -- The alias_xyz alias is used again, the path is the same
  (SELECT count(DISTINCT alias_xyz.first_name) 

  -- And the book table is patched again with the same left join
   FROM (
     book 
       LEFT JOIN author AS alias_xyz 
       ON book.author_id = author.id
  )),

  -- Outer book, again
  (SELECT alias_xyz.first_name)
FROM (
  book 
    LEFT JOIN author AS alias_xyz 
    ON book.author_id = author.id
)

这听起来很复杂,但它真的非常好用。

也许,在未来,表达式树转换将比修补结果字符串更受欢迎。到目前为止,在目前的应用中,这是阻力最小、性能最高的路径。

猜你喜欢

转载自juejin.im/post/7126374620164259848