用SQL实现一个通用的REDUCE聚合函数

417 阅读6分钟

所以,@rotnroll666的书呆子又狙击了我。显然,Neo4j的Cypher查询语言支持任意缩减,就像任何功能集合API,哦,比如JDK的Stream API:

Stream.of(2, 4, 3, 1, 6, 5)
      .reduce((i, j) -> i * j)
      .ifPresent(System.out::println); // Prints 720

SQL没有这个功能,但如果能偶尔做到这一点,那就非常有用了。在SQL中可以 "很容易 "地实现一个任意的减少。让我们看一下上面的乘法还原。在PostgreSQL中,你会这样写:

with t(i) as (values (2), (4), (3), (1), (6), (5))
select 
  (
    with recursive
      u(i, o) as (
         select i, o
         from unnest(array_agg(t.i)) with ordinality as u(i, o)
      ),
      r(i, o) as (
        select u.i, u.o from u where o = 1
        union all 
        select r.i * u.i, u.o from u join r on u.o = r.o + 1
        --     ^^^^^^^^^ reduction
      )
    select i from r
    order by o desc
    limit 1
  )
from t;

Woah.这有点像语法上的怪兽。让我们把它分解一下。

聚合函数

首先,如果我们要对这些值进行求和,我们会使用内置的SUM 函数,像这样:

with t(i) as (values (2), (4), (3), (1), (6), (5))
select sum(i)
from t;

这将产生21。如果你愿意失去精确度,你可以用对数来模仿PRODUCT() 。但是我们写了REDUCE() ,一个假想的,像这样:

with t(i) as (values (2), (4), (3), (1), (6), (5))
select reduce(
  t1.i * t2.i referencing accumulated as t1, accumulating as t2
)
from t;

这是SQL,所以lambda表达式显然会使用大量的关键字,对这个特定的函数来说是完全新颖和独特的,而且你需要jOOQ来使它可组合😁。基本上,我们会有某种基于两个伪表的还原表达式:

  • 包含结果的累积表
  • 累积表(或者说是行)

还原是一个通用的聚合函数,对组进行操作。所以,我们将不得不重新使用一些SQL聚合函数机制来实现所需的行为。

使用ARRAY_AGG()来获得聚合效果

首先,让我们做一些聚合。PostgreSQL的ARRAY_AGG() 是这项工作的完美选择,因为它可以

  • 聚合
  • 但又能让数据不受影响,这一点与例如SUM()

在某种程度上,它是一个像Stream.collect() 的集合,而不是一个减少。

如果我们在一个相关的子查询中使用ARRAY_AGG() ,我们仍然会得到聚集的效果,但是我们可以把数组再次解嵌到一个表中,以便对其进行操作。你可以在下面的例子中看到这一点:

with t(i) as (values (2), (4), (3), (1), (6), (5))
select 
  (
    select string_agg(i::text, ', ')
    from unnest(array_agg(t.i)) as u(i)
  )
from t;

这就产生了:

2, 4, 3, 1, 6, 5

这不是一件非常有用的事情,聚合、取消嵌套、再聚合,但是它显示了在相关子查询的FROM 子句中嵌套聚合函数的力量。如果你的RDBMS没有数组,也许你可以用JSON_ARRAYAGGJSON_TABLE ,或者XMLAGGXMLTABLE 做同样的事情。

免责声明:PostgreSQL经常做正确的事情™。我认为你很难在其他大多数RDBMS中像这样优雅地玩弄SQL语法,所以这种方法不具有可移植性。但是,正如Lætitia Avrot所说的那样,很优雅。

人们进入#PostgreSQL可能是因为它是免费的,但他们留下来是因为它很好。

- Lætitia (@l_avrot)2021年2月5

下一步,生成行数

在我们的例子中,主要有两种方法可以生成行号:

对我们之前的例子进行修改,以实现一些可视化:

with t(i) as (values (2), (4), (3), (1), (6), (5))
select 
  (
    select string_agg(row(i, o)::text, ', ')
    from unnest(array_agg(t.i)) with ordinality as u(i, o)
  )
from t;

(太棒了,那个行构造器!)

这就产生了:

(2,1), (4,2), (3,3), (1,4), (6,5), (5,6)

看上去并不复杂,但想象一下我们按偶数分组:

with t(i) as (values (2), (4), (3), (1), (6), (5))
select 
  i % 2,
  (
    select string_agg(row(i, o)::text, ', ')
    from unnest(array_agg(t.i)) with ordinality as u(i, o)
  )
from t
group by i % 2;

现在的结果是:

i % 2string_agg
0(2,1), (4,2), (6,3)
1(3,1), (1,2), (5,3)

这有点奇怪,对吗?我们在外层查询中GROUP BY ,而整个相关的子查询是基于其FROM 子句包含ARRAY_AGG() 的聚合函数。这和这个查询没有太大的区别:

with t(i) as (values (2), (4), (3), (1), (6), (5))
select 1 + sum(i) + 2
from t;

我们已经习惯于经常从聚合函数中构建标量表达式。这没有什么花哨的。我们也可以很容易地把这个函数包在另一个子查询中:

with t(i) as (values (2), (4), (3), (1), (6), (5))
select (select 1 + sum(i) + 2)
from t;

从这里开始,将聚合函数在标量子查询中的方法扩展到FROM 子句,然后再次取消聚合的嵌套,这并不遥远。这可能不会立即 "点击"。SQL中的GROUP BY 子句在语法上有点奇怪

备注 :遗憾的是,PostgreSQL不允许在FROM 子句中使用聚合函数,就像在相关的子查询中一样,在同一查询级别上使用聚合函数。我本来想展示一个花哨的LATERAL ,但这并不可行(目前)。

现在,递归

最后一点是与r 表的递归:

with t(i) as (values (2), (4), (3), (1), (6), (5))
select 
  (
    with recursive
      u(i, o) as (
         select i, o
         from unnest(array_agg(t.i)) with ordinality as u(i, o)
      ),
      r(i, o) as (
        select u.i, u.o from u where o = 1
        union all 
        select r.i * u.i, u.o from u join r on u.o = r.o + 1
        --     ^^^^^^^^^ reduction
      )
    select i from r
    order by o desc
    limit 1
  )
from t;

我们只需在序数上递归。UNION ALL 的第一个子查询产生了我们数据的第一行,即(1, 1) 。接下来的迭代只是将r.i 的结果与下一行的u.i 的值乘以ordinality。这可能是最好的视觉显示:

r.ir.ou.i
2 = u.i (第一次迭代)12
8 = prev r.i * u.i24
24 = 前期r.i * u.i33
24 = 前期r.i * u.i41
144 = 前期r.i * u.i56
720 = 前期r.i * u.i65

最后,我们并不关心SQL的基于集合的工作方式。也就是说,我们不关心上表中显示的整个乘法集合。我们只关心最后一行,按序排列,其中包含我们在r.i中的结果。

完成了!

使用group by

就像之前显示的那样,我们可以很容易地在外层查询中添加一个GROUP BY 子句。例如,让我们将奇数和偶数分别相乘:

with t(i) as (values (2), (4), (3), (1), (6), (5))
select 
  i % 2,
  (
    with recursive
      u(i, o) as (
         select i, o
         from unnest(array_agg(t.i)) with ordinality as u(i, o)
      ),
      r(i, o) as (
        select u.i, u.o from u where o = 1
        union all 
        select r.i * u.i, u.o from u join r on u.o = r.o + 1
      )
    select i from r
    order by o desc
    limit 1
  ),
  string_agg(i::text, ' * ')
from t
group by i % 2

我又添加了一个聚合函数STRING_AGG() ,以便得到:

i % 2istring_agg
0482 * 4 * 6
1153 * 1 * 5

很好,不是吗?现在,我不能只在那里添加一个OVER() 子句。这就产生了

SQL错误[42P20]。错误:FROM中的函数不允许使用窗口函数。

也许在不久的将来,这个方法也会起作用?或者,我可能会想出另一个黑客来使它工作,如果是这样的话,我会更新这个帖子。

支持jOOQ

很明显,jOOQ很快就会支持这个:https://github.com/jOOQ/jOOQ/issues/11385。语法将再次变得更容易忍受:

ctx.select(T.I.mod(inline(2)), reduce(T.I, (i1, i2) -> i1.times(i2)))
   .from(T.I)
   .groupBy(T.I.mod(inline(2)))
   .fetch();

其他使用实际的模拟方法 CREATE AGGREGATE FUNCTION 也将在不久的将来进行调查。