你不知道的10个SQL杀手级特性

·3776 字·18 分钟
n3xtchen
作者
n3xtchen
Sharing Funny Tech With You

TLDR;

1. 一切都是表 #

这是最微不足道的技巧,甚至不算一个技巧,但是它却是你全面了解 SQL 的基础:一切都是表!但你看到下面一段语句:

SELECT * FROM person

…你很快注意到表 person。很棒,他是一张表。但是你意识到这整个语句也是一张表吗?举个例子,你可以写:

SELECT * FROM (
  SELECT * FROM person
) t

现在,你已经创建一张派生表(derived table)- 一张嵌套在 FROM 子句的 SELECT 语句。

虽然这功能微不足道,但是很优雅。你也可创建一张使用 VALUES() 的 ad-hoc 的内存表,在一些数据库中(如,PostgreSQLSQL Server

SELECT * FROM (VALUES(1), (2), (3))) t(a)

以及她的简单输出:

 a
---
 1
 2
 3

如果这个语法不知大,你可以把他转化成派生表,如,在 Oracle 中:

SELECT * FROM (
	SELECT 1 AS a FROM DUAL UINION ALL
	SELECT 2 AS a FROM DUAL UINION ALL
	SELECT 3 AS a FROM DUAL
)

正如你所看到的, VALUES() 和派生表实际上都是一样的东西,从概念上讲。让我们重温下插入语句,两种方法:

-- SQL Server, PostgreSQL, some others:
INSERT INTO my_table(a) VALUES (1), (2), (3);

-- Oracle, many others:
INSERT INTO my_table(a)
	SELECT 1 AS a FROM DUAL UINION ALL
	SELECT 2 AS a FROM DUAL UINION ALL
	SELECT 3 AS a FROM DUAL

SQL 中,一切都是表。当你插入数据到表,你并不是在插入独立的行。你实际上插入的是一张表。大部分人经常只在表冲插入一张一行的表,因此意识不到。

一切都是表。在 PostgreSQL 中,甚至连函数都是表:

ichexw=# SELECT * FROM substring('abcde', 2, 3);
 substring
-----------
 bcd
(1 row)

如果你是个 JAVA 程序员,你可以使用 JAVA8 Stream API 来类比它。考虑下,下面的相同的概念

TABLE:	Stream<Tuple<..>>
SELECT:	map()
DISTINCT:	distinct()
JOIN:		flatMap()
WHERE/HAVING:	filter()
GROUP BY:	collect()
ORDER BY:	sorted()
UNION ALL:	concat()

使用 JAVA8 的过程中,“一切都是流”(至少在开始使用 Streams 的时候)。不管你怎么转换流,比如使用 map()filter(),结果的类型永远都是一个流

2. 使用递归SQL来生成数据 #

公用表表达式(Common Table Express,也叫 CTE)是 SQL 中声明变量的唯一方法(有别于 PostgreSQLSybase SQL 中的 Window 语句)

她是一个强大的概念。异常强大。看看下面的语句:

ichexw=# WITH
  t1(v1, v2) AS (SELECT 1, 2),
  t2(w1, w2) AS (
    SELECT v1 * 2, v2 * 2
    FROM t1
  )
SELECT *
FROM t1, t2;
 v1 | v2 | w1 | w2
----+----+----+----
  1 |  2 |  2 |  4
(1 row)

使用一个简单的 WITH 语法,你可以指定一个 Table 对象列表(记住:一切都是表),她们可以相互调用。

这个很容易理解。这使得 CTE 很有用,并且她们还允许递归这种逆天的特性!现在看看下面的 PostgreSQL 语句:

ichexw=# WITH RECURSIVE t(v) AS (
  SELECT 1     -- Seed Row
  UNION ALL
  SELECT v + 1 -- Recursion
  FROM t
)
SELECT v
FROM t
LIMIT 5;
 v
---
 1
 2
 3
 4
 5
(5 rows)

是不是很不错?看看注解,还是挺简单易用的。你定义了一个 CTE 实际上就是两个 UNION ALL 子查询

第一个 UNION ALL 子查询称之为 “种子行(SEED ROW)”。她初始化了递归。她可以在后续的递归中生成一个或多行。记住:一切都是表,因此我们的递归生成的也是表,而不是一个独立的行或值。

第二个 UNION ALL 子查询进行递归。如果你认真看,你会发现她是从 t 中检索数据的;例如 第二个子查询允许从我们声明的 CTE 中查询。递归。因此,她也能访问 CTE 中定义的 v 字段。

在我们的例子中,把 row(1) 作为递归的种子,然后进行 v+1 的递归。这个递归通过设置一个 LIMIT 5 来作为停止条件的(是不是很很像 JAVA8 STREAM 中的潜在无限递归)。

Side note:图灵完备 #

递归 CTE 使 SQL:1999 具有图灵完备,她意味着任何其他语言都可以使用 SQL 来重写!(如果你足够疯狂的话)

下面可能在很多博客经常看到的一个令人影响深刻的例子:曼德博集合(The Mandelbrot Set), e.g. 比如在 on http://explainextended.com/2013/12/31/happy-new-year-5/

ichexw=# WITH RECURSIVE q(r, i, rx, ix, g) AS (
SELECT r::DOUBLE PRECISION * 0.02, i::DOUBLE PRECISION * 0.02,
    .0::DOUBLE PRECISION      , .0::DOUBLE PRECISION, 0
FROM generate_series(-60, 20) r, generate_series(-50, 50) i
UNION ALL
SELECT r, i, CASE WHEN abs(rx * rx + ix * ix) <= 2 THEN rx * rx - ix * ix END + r,
           CASE WHEN abs(rx * rx + ix * ix) <= 2 THEN 2 * rx * ix END + i, g + 1
FROM q
WHERE rx IS NOT NULL AND g < 99
)
SELECT array_to_string(array_agg(s ORDER BY r), '')
FROM (
SELECT i, r, substring(' .:-=+*#%@', max(g) / 10 + 1, 1) s
FROM q
GROUP BY i, r
) q
GROUP BY i
ORDER BY i;
finance=# WITH RECURSIVE q(r, i, rx, ix, g) AS (
SELECT r::DOUBLE PRECISION * 0.02, i::DOUBLE PRECISION * 0.02,
    .0::DOUBLE PRECISION      , .0::DOUBLE PRECISION, 0
FROM generate_series(-60, 20) r, generate_series(-50, 50) i
UNION ALL
SELECT r, i, CASE WHEN abs(rx * rx + ix * ix) <= 2 THEN rx * rx - ix * ix END + r,
           CASE WHEN abs(rx * rx + ix * ix) <= 2 THEN 2 * rx * ix END + i, g + 1
FROM q
WHERE rx IS NOT NULL AND g < 99
)
SELECT array_to_string(array_agg(s ORDER BY r), '')
FROM (
SELECT i, r, substring(' .:-=+*#%@', max(g) / 10 + 1, 1) s
FROM q
GROUP BY i, r
) q
GROUP BY i
ORDER BY i;
----------------------------------------------------------------------------------
                                                     ..... ..@
                                                     ..:..:.
                                                      .....
                                                      ..:..
                                                      ..:..
                                                     ..-:..
                                                 .....=@#+:
                                                ....:.=@@=.....
                                               :.-..+@*@*::..:.
                                               ..:-@@@@@@@@:.:-.
                                                ..@@@@@@@@@+%..
                                                ..@@@@@@@@@@-..
                                                :-*@@@@@@@@@:-:
                                               ..:@@@@@@@@@@@..
                                              ...*@@@@@@@@@@:..
                                   .        ......-@@@@@@@@@....               .
                                .....    ..:.......=@@@@@@@-........:          ..
                              .-.:-.......==..*.=.::-@@@@@:::.:.@..*-.         =.
                              ...=...=...::+%.@:@@@@@@@@@@@@@+*#=.=:+-.      ..-
                              .:.:=::*....@@@@@@@@@@@@@@@@@@@@@@@@=@@.....::...:.
                              ...*@@@@=.@:@@@@@@@@@@@@@@@@@@@@@@@@@@=.=....:...::.
                               .::@@@@@:-@@@@@@@@@@@@@@@@@@@@@@@@@@@@:@..-:@=*:::.
                               .-@@@@@-@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@.=@@@@=..:
                               ...@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@:@@@@@:..
                              ....:-*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@::
                             .....@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@-..
                           .....@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@-:...
                          .--:+.@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@...
                          .==@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@-..
                          ..+@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@-#.
                          ...=+@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@..
                          -.=-@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@..:
                         .*%:@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@:@-
  .    ..:...           ..-@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
 ..............        ....-@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%@=
 .--.-.....-=.:..........::@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@..
 ..=:-....=@+..=.........@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@:.
 .:+@@::@==@-*:%:+.......:@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@.
 ::@@@-@@@@@@@@@-:=.....:@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@:
 .:@@@@@@@@@@@@@@@=:.....%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
 .:@@@@@@@@@@@@@@@@@-...:@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@:-
 :@@@@@@@@@@@@@@@@@@@-..%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@.
 %@@@@@@@@@@@@@@@@@@@-..-@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@.
 @@@@@@@@@@@@@@@@@@@@@::+@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+
 @@@@@@@@@@@@@@@@@@@@@@:@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@..
 @@@@@@@@@@@@@@@@@@@@@@-@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@-
 @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@.
 @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@..
 @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@.
:

被 shock 到了吧?

3. 计算运行总计 #

Microsoft Excel 中,你会简单地计算前两个(或后续)的值之和(或差),然后使用有用的十字光标在整个电子表格中拖动该公式。

您通过电子表格计算运行总计。

在 SQL 的世界中,最好的方法就是使用窗口函数(Window Function)。

窗口函数是一个很强大的概念-首先,她表面上看不那么好理解,但实际上真的很简单:

窗口函数是相对于由SELECT转换的当前行的行的子集上的聚合/排名

就这么简单,^_^

它本质上意味着窗口函数可以对当前行“之上”或“之下”的行执行计算。不像常规的聚合和分组,它们不会转换行,这使它们非常有用。

语法可以归纳如下:

function(...) OVER (
  PARTITION BY ...
  ORDER BY ...
  ROWS BETWEEN ... AND ...
)

一次,我有各种排序函数(我们将在后面一一解释这些函数),仅接着是 OVER() 分句,她指定了窗口,定义如下:

  • PARTITION: 只有与当前行在同一分区中的行才会被视为该窗口
  • ORDER: 我们可以给筛选出来的窗口进行排序
  • ROWS(或 RANGE)帧定义:窗口可以被限制为“之前”和“之后”的固定量的行。

这就是窗口函数的全部。

现在,我们来看看,她如何为我们实现计算运行总计?下面是数据

IDVALUE_DATEAMOUNTBALANCE
99972014-03-1899.1719985.81
99812014-03-1671.4419886.64
99792014-03-16-94.6019815.20
99772014-03-16-6.9619909.80
99712014-03-15-65.9519916.76

我们假设 BALANCE 就是我们想要数值。

直观地,我们可以立即看出规律(看加重符号,加上符号)

IDVALUE_DATEAMOUNTBALANCE
99972014-03-18-(99.17)+19985.81
99812014-03-16-(71.44)19886.64
99792014-03-16-(-94.60)19815.20
99772014-03-16-6.96=19909.80
99712014-03-15-65.9519916.76

因此,我们可以使用下面伪 SQL 语句来表达任何的任何余额:

当前的月 - SUM(进出款项金额) OVER (
  "当前行之上的所有行"
)

实际的 SQL 可以写成这样:

SUM(t.amount) OVER (
  PARTITION BY t.account_id 
  ORDER BY     t.value_date DESC, t.id DESC
  ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
)

解释如下:

  • 分区可以计算每一个银行账户的汇总,而不是全部数据的
  • 汇总之前,排序保证交易在分区内是有序的
  • ROWS 分句讲只考虑分区内之前的行(给定的排序)的汇总

所有这些都将发生在内存中的数据集已经由您在FROM .. WHERE等子句中选择,因此非常快。

中场休息 #

在我们介绍其他技巧之前,大家思考下:我们已经看到了

  • (递归)Common Table Expressions(公共表表达式,CTE)
  • 窗口函数

她们都有共同的特征:

  • 很棒
  • 异常强大
  • 声明式
  • SQL 标准的一部分
  • 在大部分关系型数据库可用(除了 MySQL)
  • 非常重要构件

如果一定要从这个文章中得出什么结论的话,那就是你绝对应该知道现代的SQL这两个重要构件。为什么?可以从这个 站点中 中得到答案。

4. 寻找连续无间隔的最长子序列 #

很多应用或者网站为了刺激用户活跃留存,对连续登录的用户进行奖励。比如,StackOverflow 的徽章:

  • Enthusianst: 连续30天访问每天都访问的用户
  • Fanatic:连续100天访问每天都访问的用户

那我们如何计算这些徽章呢?这些徽章用来奖励给连续使用他们平台指定天数的用户。不管婚礼或者结婚纪念日,你也必须登录,否则计数就会归0。

正如我们所使用的是声明式编程,我不需要当心维护任何状态和内存计数。我们想要使用在线分析 SQL 的形式表达她。例如,看看这些数据(测试数据生成方法见附录-1):

n3xt-test=# SELECT login_time FROM user_login WHERE id = :user_id;
     login_time
---------------------
 2017-02-17 16:00:00
 2017-02-16 20:00:00
 2017-02-16 03:00:00
 2017-02-15 21:00:00
 2017-02-15 20:00:00
 2017-02-14 01:00:00
 2017-02-12 09:00:00
 2017-02-11 00:00:00
 2017-02-10 20:00:00
 2017-02-10 10:00:00
 2017-02-09 20:00:00
 2017-02-09 05:00:00
 2017-02-08 19:00:00
(13 rows)

一点帮助都没有。让我们从时间戳中去掉小时,并去重。这很简单:

n3xt-test=# SELECT DISTINCT CAST(login_time AS DATE) login_date FROM user_login WHERE id = :user_id;
 login_date
------------
 2017-02-17
 2017-02-16
 2017-02-15
 2017-02-14
 2017-02-12
 2017-02-11
 2017-02-10
 2017-02-09
 2017-02-08
(9 rows)

就是现在,使用我们已经学过的窗口函数,让我们给每一个日期加上简单的行数:

n3xt-test=# SELECT
    login_date,
    row_number() OVER (ORDER BY login_date)
FROM login_date;
 login_date | row_number
------------+------------
 2017-02-08 |          1
 2017-02-09 |          2
 2017-02-10 |          3
 2017-02-11 |          4
 2017-02-12 |          5
 2017-02-14 |          6
 2017-02-15 |          7
 2017-02-16 |          8
 2017-02-17 |          9
(9 rows)

接下来仍然很简单。看看发生了什么,如果不单独选择这些值,我们减去它们?

n3xt-test=# SELECT
    login_date,
    (row_number() OVER (ORDER BY login_date)),
    login_date - (row_number() OVER (ORDER BY login_date))::INT grp
FROM login_date;
 login_date | row_number |    grp
------------+------------+------------
 2017-02-08 |          1 | 2017-02-07
 2017-02-09 |          2 | 2017-02-07
 2017-02-10 |          3 | 2017-02-07
 2017-02-11 |          4 | 2017-02-07
 2017-02-12 |          5 | 2017-02-07
 2017-02-14 |          6 | 2017-02-08
 2017-02-15 |          7 | 2017-02-08
 2017-02-16 |          8 | 2017-02-08
 2017-02-17 |          9 | 2017-02-08
(9 rows)

上述这些简单例子来说明了:

  1. ROW_NUMBER() 不言而喻,不会有间隔。
  2. 然而我们的数据有

因此,我们把不连续有间隔的时间序列减去一个连续的整数序列,得到的新的日期相同的时间处在同一个连续日期:

n3xt-test=# SELECT
  min(login_date), max(login_date),
  max(login_date) -
  min(login_date) + 1 AS length
FROM login_date_groups
GROUP BY grp
ORDER BY length DESC;
    min     |    max     | length
------------+------------+--------
 2017-02-08 | 2017-02-12 |      5
 2017-02-14 | 2017-02-17 |      4
(2 rows)

下面是完整的查询语句:

1 WITH login_date AS (
2     SELECT DISTINCT CAST(login_time AS DATE) login_date
3     FROM user_login
4     WHERE id = 1
5 ), login_date_groups AS (
6     SELECT
7         login_date,
8         (row_number() OVER (ORDER BY login_date)),
9         login_date - (row_number() OVER (ORDER BY login_date))::INT grp
10     FROM login_date
11 )
12 SELECT
13   min(login_date), max(login_date),
14   max(login_date) -
15   min(login_date) + 1 AS length
16 FROM login_date_groups
17 GROUP BY grp
18 ORDER BY length DESC;

5. 寻找序列长度 #

上一个例子,我们已经提取连续值的序列。很简单,我们几乎滥用了整数连续队列。倘若序列的定义不够直观,?看看接下来的数据,LENGTH 是我们想计算的每一个序列的长度:

idamountlength
2013.973
1921.133
1884.723
17-18.912
16-65.992
1518.071
14-52.681
1316.871
12-56.762
11-94.722
1095.461
9-52.451

是的,你的猜测是正确,这个是收支方向(SIGN(AMOUNT))相同根据订单ID排序生成的连续序列,看下格式化后的数据:

idamountlength
20+13.973
19+21.133
18+84.723
17-18.912
16-65.992
15+18.071
14-52.681
13+16.871
12-56.762
11-94.722
10+95.461
9-52.451

那我们要怎么做?太简单,首先去除所有的噪音,加入行数

n3xt-test=# SELECT
  id, amount,
  sign(amount) AS sign,
  row_number()
    OVER (ORDER BY id DESC) AS rn
FROM orders;
 id | amount | sign | rn
----+--------+------+----
 20 |  13.97 |    1 |  1
 19 |  21.13 |    1 |  2
 18 |  84.72 |    1 |  3
 17 | -18.91 |   -1 |  4
 16 | -65.99 |   -1 |  5
 15 |  18.07 |    1 |  6
 14 | -52.68 |   -1 |  7
 13 |  16.87 |    1 |  8
 12 | -56.76 |   -1 |  9

下一个目标是生成下面这样的表:

idamountsignrnlohi
2013.97111
1921.1312
1884.72133
17-18.91-144
16-65.99-155
1518.071666
14-52.68-1777
1316.871888
12-56.76-199
11-94.72-11010
1095.461111111

在这个表中,我想复制行数到一个子系列的起始行(下界)的 LO 字段,和结束行(上界)的 HI 字段中。为了这个,我们需要使用两个魔法函数 LEAD()LAG()

  • LEAD():当前行的下 n 行

  • LAG():当前行的上 n 行

      n3xt-test=# SELECT
        lag(v) OVER (ORDER BY v),
        v,
        lead(v) OVER (ORDER BY v)
      FROM (
        VALUES (1), (2), (3), (4)
      ) t(v);
       lag | v | lead
      -----+---+------
           | 1 |    2
         1 | 2 |    3
         2 | 3 |    4
         3 | 4 |
      (4 rows)
    

很神奇有木有?记住,在窗口函数内,你可以对 和当前相关的行的子集 进行排行或者聚合。在 LEAD()LAG() 的例子中,我们访问当前行相关的行,重要指定偏离位置,是很容易的。在很多场景中时很有用的。

继续我的 LOHGIH 例子:

SELECT
  trx.*,
  CASE WHEN lag(sign)
       OVER (ORDER BY id DESC) != sign
       THEN rn END AS lo,
  CASE WHEN lead(sign)
       OVER (ORDER BY id DESC) != sign
       THEN rn END AS hi
FROM trx;

通过与上一行(lag())对比 sign 字段,如果他们符号相反,我们把当前的行数复制到 LO 字段,因为这是我们序列的下界。

然后通过与下一行(lead())对比 sign 字段,如果他们符号相反,我们把当前的行数复制到 LO 字段,因为这是我们序列的上界。

最后,我们需要处理讨厌的空值(NULL):

SELECT -- With NULL handling...
  trx.*,
  CASE WHEN coalesce(lag(sign)
       OVER (ORDER BY id DESC), 0) != sign
       THEN rn END AS lo,
  CASE WHEN coalesce(lead(sign)
       OVER (ORDER BY id DESC), 0) != sign
       THEN rn END AS hi
FROM trx;

下一步,我们想要 LOHI 出现在我们的所有行中。

idamountsignrnlohi
2013.971113
1921.131213
1884.721313
17-18.91-1445
16-65.99-1545
1518.071666
14-52.68-1777
1316.871888
12-56.76-19910
11-94.72-110910
1095.461111111

我们所使用的特性至少在 Redshift,Sybase SQL,DB2 以及 Oracle 中都可用。我们使用 IGNORE NULLS 语句:

SELECT 
  trx.*,
  last_value (lo) IGNORE NULLS OVER (
    ORDER BY id DESC 
    ROWS BETWEEN UNBOUNDED PRECEDING 
    AND CURRENT ROW) AS lo,
  first_value(hi) IGNORE NULLS OVER (
    ORDER BY id DESC 
    ROWS BETWEEN CURRENT ROW 
    AND UNBOUNDED FOLLOWING) AS hi
FROM trx

很多关键字!但是本质往往是相同的。在任何给定的当前行,我们寻找之前的值(previous values,ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW),但是忽略所有的空值。从之前的之中,我们获取最后的值,和我们的新 LO 值。换句话说,我们获取向前最接近当前行(closest preceding)的 LO 值。

HI 也是同理。在任何给定的当前行,我们寻找随后的值(subsequent values,ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW),但是忽略所有的空值。从之前的之中,我们获取最后的值,和我们的新 HI 值。换句话说,我们获取向后最接近当前行(closest following)的 HI 值。

SELECT -- With NULL handling...
  trx.*,
  coalesce(last_value (lo) IGNORE NULLS OVER (
    ORDER BY id DESC 
    ROWS BETWEEN UNBOUNDED PRECEDING 
    AND CURRENT ROW), rn) AS lo,
  coalesce(first_value(hi) IGNORE NULLS OVER (
    ORDER BY id DESC 
    ROWS BETWEEN CURRENT ROW 
    AND UNBOUNDED FOLLOWING), rn) AS hi
FROM trx

最后,我们只是做一个微不足道的最后一步,记住处理 off-by-1 错误:

SELECT
  trx.*,
  1 + hi - lo AS length
FROM trx

这个是我们最后的结果:

idamountsignrnlohilength
2013.9711133
1921.1312133
1884.7213133
17-18.91-14452
16-65.99-15452
1518.0716661
14-52.68-17771
1316.8718881
12-56.76-199102
11-94.72-1109102
1095.4611111111

下面是完整版的查询:

WITH 
  trx1(id, amount, sign, rn) AS (
    SELECT id, amount, sign(amount), row_number() OVER (ORDER BY id DESC)
    FROM trx
  ),
  trx2(id, amount, sign, rn, lo, hi) AS (
    SELECT trx1.*,
    CASE WHEN coalesce(lag(sign) OVER (ORDER BY id DESC), 0) != sign 
         THEN rn END,
    CASE WHEN coalesce(lead(sign) OVER (ORDER BY id DESC), 0) != sign 
         THEN rn END
    FROM trx1
  )
SELECT 
  trx2.*, 1
  - last_value (lo) IGNORE NULLS OVER (ORDER BY id DESC 
    ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
  + first_value(hi) IGNORE NULLS OVER (ORDER BY id DESC 
    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
FROM trx2

由于 PostgreSQL 没有 IGNORE NULLS 语句,所以我在给出一个实现:

WITH trx AS (
  SELECT
    id, amount,
    sign(amount) AS sign,
    row_number()
      OVER (ORDER BY id DESC) AS rn
  FROM orders
), trx1 AS (
  SELECT
    trx.*,
    CASE WHEN coalesce(lag(sign)
         OVER (ORDER BY id DESC), 0) != sign
         THEN rn END AS lo,
    CASE WHEN coalesce(lead(sign)
         OVER (ORDER BY id DESC), 0) != sign
         THEN rn END AS hi
  FROM trx
), trx2 AS (
 SELECT -- 数据对齐
    trx1.*,
    sum(case when lo is null then 0 else 1 end) over (order by id desc) as lo_partition
 FROM trx1
)
SELECT
  trx2.id, trx2.amount, trx2.sign, trx2.rn,
  max(trx2.lo) OVER (PARTITION BY trx2.lo_partition) lo,
  max(trx2.hi) OVER (PARTITION BY trx2.lo_partition) hi,
  max(trx2.hi) OVER (PARTITION BY trx2.lo_partition) -
  max(trx2.lo) OVER (PARTITION BY trx2.lo_partition) + 1 length
FROM trx2;

6. 子集和問題(The subset sum problem with SQL) #

什么是子集和问题?这里进行了有趣的解释:

https://xkcd.com/287

还是维基百科上乏味的解释:

子集和问题

本质上,对每一个的求和。。。

IDTOTAL
125150
219800
327511

我想要尽可能地从这些组合项中找到“最好”的和:

IDITEM
17120
28150
38255
49051
51220
612515
713555
85221
9812
106562

如果你心算够好的话,你可以直接得出最佳的和:

TOTALSUMCALC
25150251337120 + 8150 + 9051 + 812
19800197681220 + 12515 + 5221 + 812
27511274888150 + 8255 + 9051 + 1220 + 812

使用 SQL 怎么处理呢?简单,只需要使用创建一个 CTE,枚举出 2的n次方种减1个组合,并找到最接近的一个:

-- 枚举所有的组合,2**n - 1 组合
WITH sums(sum, max_id, calc) AS (...)
-- 找出最接近 total 的那一条
SELECT
  totals.total,
  something_something(total - sum) AS best,
  something_something(total - sum) AS calc
FROM draw_the_rest_of_the_*bleep*_owl

如果你读到这里,说明我们是真朋友,^_^

不要担心,方法并没有想象中那么难。

首先,我们需要枚举所有子集合(实现全排列的算法),这个比较简单:

WITH RECURSIVE
assign(id, total) AS ( ... ),
vals(id, item) AS (...),
sums (start_id, max_id, sum, cacl) AS (
  SELECT id, id, item, item::text FROM vals
  UNION ALL
  SELECT
    sums.start_id, t.id, sum+item,
    cacl|| '+' || item::text
  FROM sums JOIN vals t ON sums.max_id < t.id
)
SELECT * FROM sums

然后,我们把需要结果从候选组合中找到最接近的组合:

SELECT total, sum, cacl
FROM assign a, LATERAL (
    SELECT sum, cacl FROM sums
    ORDER BY ABS(a.total - sum)
    FETCH FIRST 1 ROW ONLY	-- 等同于 limit 1,这个是 SQL 标准,limit 不是
) b;

ASSIGN 的每个值通过和 SUMS 连表获取按照排序的的一行的值。我们需要使用 LATERAL,因为他允许我们访问左边的表的字段,否则正常的 JOIN 无法直接获取的。

同样的功能在 SQL Server(它的关键字是 CROSS APPLY) 也支持。

在连表的右侧的结果依赖于左边的时候,LATERAL 可能很有用。与普通连接不同,这意味着 JOIN 顺序将从左到右依次设置,优化器具有一组减少的连接算法选项。想现在这个场景(带着 ORDER BYFETCH FRIST),或者连接非嵌套的表值函数。

下面是完整的查询。

n3xt-test=# WITH RECURSIVE
assign(id, total) AS (
            SELECT 1, 25150
  UNION ALL SELECT 2, 19800
  UNION ALL SELECT 3, 27511
),
vals (id, item) AS (
            SELECT 1 , 7120
  UNION ALL SELECT 2 , 8150
  UNION ALL SELECT 3 , 8255
  UNION ALL SELECT 4 , 9051
  UNION ALL SELECT 5 , 1220
  UNION ALL SELECT 6 , 12515
  UNION ALL SELECT 7 , 13555
  UNION ALL SELECT 8 , 5221
  UNION ALL SELECT 9 , 812
  UNION ALL SELECT 10, 6562
),
sums (start_id, max_id, sum, cacl) AS (
  SELECT id, id, item, item::text FROM vals
  UNION ALL
  SELECT
    sums.start_id, t.id, sum+item,
    cacl|| '+' || item::text
  FROM sums JOIN vals t ON sums.max_id < t.id
)
SELECT total, sum, cacl
FROM assign a, LATERAL (
    SELECT sum, cacl FROM sums
    ORDER BY ABS(a.total - sum)
    FETCH FIRST 1 ROW ONLY
) b;
 total |  sum  |          cacl
-------+-------+-------------------------
 25150 | 25133 | 7120+8150+9051+812
 19800 | 19768 | 1220+12515+5221+812
 27511 | 27488 | 8150+8255+9051+1220+812
(3 rows)

7. 覆盖运行中的汇总 #

之前,我们已经知道怎么使用窗口函数计算“一般的”运行中汇总。很简单。现在,如果我们想要覆盖运行中的汇总,使得她永远大于0?基本上,我们星耀计算这个:

DATEAMOUNTTOTAL
2012-01-01800800
2012-02-0119002700
2012-03-0117504450
2012-04-01-200000
2012-05-01900900
2012-06-0139004800
2012-07-01-26002200
2012-08-01-26000
2012-09-0121002100
2012-10-01-24000
2012-11-0111001100
2012-12-0113002400

当一笔很大支出 -20000 被剪去,我将其归0即可,而不是显示世纪的 -15550。看我的注释就明白了:

DATEAMOUNTTOTALTotal 的公式
2012-01-01800800GREATEST(0, 800)
2012-02-0119002700GREATEST(0, 2700)
2012-03-0117504450GREATEST(0, 4450)
2012-04-01-200000GREATEST(0, -15550)
2012-05-01900900GREATEST(0, 900)
2012-06-0139004800GREATEST(0, 4800)
2012-07-01-26002200GREATEST(0, 2200)
2012-08-01-26000GREATEST(0, -400)
2012-09-0121002100GREATEST(0, 2100)
2012-10-01-24000GREATEST(0, -300)
2012-11-0111001100GREATEST(0, 1100)
2012-12-0113002400GREATEST(0, 2400)

我们怎么做呢?窗口函数和递归CTE都是可以实现,看到这里大家估计也已经视觉疲劳了,我们换个新法子?但是这个法子只有 Oracle,vendor-specific SQL。

将会非常的惊艳,只需要在任何报表的后面加上 MODEL

SELECT ... FROM some_table

-- 放在任何表后面	 
MODEL ...

然后你就可以直接在 SQL 语句中实现电子表格的逻辑,和 Excel 一样。

下面是接下来三个语句将非常实用和广泛的使用

MODEL
  -- 维度
  DIMENSION BY ...
   
  -- 报表字段
  MEASURES ...
   
  -- 公司
  RULES ...

稍微解释下:

  • DIMENSION BY:指定电子表格的维度。不像 Excel,你可以在 Oracle 中指定任意数量的维度,而不是2个。
  • MEASURES:可用的值。不像 Excel ,在单元格中可以使用元祖,而不是单一的值。
  • RULES:每一个单元格的公式。不像 Excel,这个公式集中放在这里,而不是在每一个单元格中。

使得 MODEL 使用起来比 Excel 难一些,但是功能更强大,如果你敢用。下面给一个小 demo:

SELECT *
FROM (
  SELECT date, amount, 0 AS total
  FROM amounts
)
MODEL 
  DIMENSION BY (row_number() OVER (ORDER BY date) AS rn)
  MEASURES (date, amount, total)
  RULES (
    total[any] = greatest(0,
    coalesce(total[cv(rn) - 1], 0) + amount[cv(rn)])
  )

8. 时间序列模式识别(Time Series Pattern Recognition) #

如果你对诈骗识别或者其他运行实时大数据的领域感兴趣,时间模式识别这个名词对你来说将不会太陌生。

如果我们重温 5. 寻找序列长度 的章节,将会想在我们时间序列的复杂事件(Event)上生成触发器(Trigger):

IDVALUE_DATEAMOUNTLENTRIGGER
99972014-03-18+ 99.171
99812014-03-16- 71.444
99792014-03-16- 94.604x
99772014-03-16- 6.964
99712014-03-15- 65.954
99642014-03-15+ 15.133
99622014-03-15+ 17.473
99602014-03-15+ 3.553
99592014-03-14- 32.001

触发器的规则是:

如果某个事件连续发生3次,则触发该触发器(Trigger)。

也和之前的 MODEL 语句类似,我们能做的就是使用 Oracle 12c 语法:

SELECT ... FROM some_table	 
MATCH_RECOGNIZE (...) 

MATCH_RECOGNIZE 的最简单的应用包括以下子句:

SELECT *
FROM series
MATCH_RECOGNIZE (
  -- 模式匹配在这个顺序下完成
  ORDER BY ...
 
  -- 用来匹配的字段
  MEASURES ...
 
  -- 每一次匹配后返回行的配置
  ALL ROWS PER MATCH
 
  -- 匹配事件的正则表达式
  PATTERN (...)
 
  -- 事件的定义
  DEFINE ...
) 

这个听起来太疯狂了。现在看一个实际的例子:

SELECT *
FROM series
MATCH_RECOGNIZE (
  ORDER BY id
  MEASURES classifier() AS trg
  ALL ROWS PER MATCH
  PATTERN (S (R X R+)?)
  DEFINE
    R AS sign(R.amount) = prev(sign(R.amount)),
    X AS sign(X.amount) = prev(sign(X.amount))
) 

我们做了什么?

  • 根据 ID 排序
  • 然后我们指定我们想要的值作为结果。 我们需要 MEASURE 触发器(Trigger),它被定义为分类器,即我们将在模式中使用的文字。 此外,我们想要匹配的所有行。
  • 我们指定类正则表达式模式。这个模式是一个 S 事件(Event)定义开始,接着 R 时间定义重复。如果全部模式匹配,我们会的得到 SRXRSRXRRSRXRRR,例如, X 将会在序列长度大于 4 的第三个位置北标记
  • 最后,我们定义 RX 成同一个事件(Event),即当前行和上一行的 SIGN(AMOUNT) 相同时触发。我们没有定义 S,他可以是任何的其他行。

这个查询会产生下面魔法般的输出:

IDVALUE_DATEAMOUNTTRG
99972014-03-18+ 99.17S
99812014-03-16- 71.44R
99792014-03-16- 94.60X
99772014-03-16- 6.96R
99712014-03-15- 65.95S
99642014-03-15+ 15.13S
99622014-03-15+ 17.47S
99602014-03-15+ 3.55S
99592014-03-14- 32.00S

我们可以看到一个 X 在我们的事件(Event)系统。这个就是实际上我们想要的。在一系列长度大于3的事件(相同符号)的第三次重复时触发。

Boom!

实际上,我们根本不 Care SR 事件(Event),只需要像这样去掉就好:

SELECT
  id, value_date, amount, 
  CASE trg WHEN 'X' THEN 'X' END trg
FROM series
MATCH_RECOGNIZE (
  ORDER BY id
  MEASURES classifier() AS trg
  ALL ROWS PER MATCH
  PATTERN (S (R X R+)?)
  DEFINE
    R AS sign(R.amount) = prev(sign(R.amount)),
    X AS sign(X.amount) = prev(sign(X.amount))
) 

最后的结果如下:

IDVALUE_DATEAMOUNTTRG
99972014-03-18+ 99.17
99812014-03-16- 71.44
99792014-03-16- 94.60X
99772014-03-16- 6.96
99712014-03-15- 65.95
99642014-03-15+ 15.13
99622014-03-15+ 17.47
99602014-03-15+ 3.55
99592014-03-14- 32.00

感谢, ORACLE

另外别要期待我继续介绍 Oracle 白皮书(如果你在使用 Oracle 12c, 那强烈建议看一下她的 文档)的其他特性了。

9. 数据表行列转换(Pivoting and Unpivoting) #

如果你已经读到这里,接下来的内容都太简单了,和大家过一下:

这是我们的数据,主演电影名以及电影评级

NAMETITLERATING
A. GRANTANNIE IDENTITYG
A. GRANTDISCIPLE MOTHERPG
A. GRANTGLORY TRACYPG-13
A. HUDSONLEGEND JEDIPG
A. CRONYNIRON MOONPG
A. CRONYNLADY STAGEPG
B. WALKENSIEGE MADRER

我们想要转换成:

NAMENC-17PGGPG-13R
A. GRANT36531
A. HUDSON124792
A. CRONYN69264
B. WALKEN88473
B. WILLIS551436
C. DENCH64545
C. NEESON38473

如果是用过 Excel透视表 的可以略过接下来的两段解释。

大家注意到了,我可以根据演员进行分组,然后把该演员每个评级分组下的电影数量转化成列(PIVOTING)。不以关系的形式显示,(例如,每组一行),我们把所有的组都转换成列。之所以可以这么做,是因为我们实现知道所有可能出现的分组。

列转行(Unpivoting) 则是相反的操作。

NAMERATINGCOUNT
A. GRANTNC-173
A. GRANTPG6
A. GRANTG5
A. GRANTPG-133
A. GRANTR6
A. HUDSONNC-1712
A. HUDSONPG4

这个实际上很简单。下面是 PostgreSQL 的实现:

SELECT
  first_name, last_name,
  count(*) FILTER (WHERE rating = 'NC-17') AS "NC-17",
  count(*) FILTER (WHERE rating = 'PG'   ) AS "PG",
  count(*) FILTER (WHERE rating = 'G'    ) AS "G",
  count(*) FILTER (WHERE rating = 'PG-13') AS "PG-13",
  count(*) FILTER (WHERE rating = 'R'    ) AS "R"
FROM actor AS a
JOIN film_actor AS fa USING (actor_id)
JOIN film AS f USING (film_id)
GROUP BY actor_id

我们可以向聚合函数附加一个简单的 FILTER 子句,以便只计算相关的数据。

在其他数据库下,我们可以这么做:

SELECT
  first_name, last_name,
  count(CASE rating WHEN 'NC-17' THEN 1 END) AS "NC-17",
  count(CASE rating WHEN 'PG'    THEN 1 END) AS "PG",
  count(CASE rating WHEN 'G'     THEN 1 END) AS "G",
  count(CASE rating WHEN 'PG-13' THEN 1 END) AS "PG-13",
  count(CASE rating WHEN 'R'     THEN 1 END) AS "R"
FROM actor AS a
JOIN film_actor AS fa USING (actor_id)
JOIN film AS f USING (film_id)
GROUP BY actor_id

现在,如果你在使用 SQL Server 或者 Oracle 的话,你还可以使用内建的 PIVOTUNPIVOT 语句,就像 MODELMATCH_RECOGNIZE 一样,在一个表的后面添加新的关键词就好:

-- 行转列
SELECT something, something
FROM some_table
PIVOT (
  count(*) FOR rating IN (
    'NC-17' AS "NC-17", 
    'PG'    AS "PG", 
    'G'     AS "G", 
    'PG-13' AS "PG-13", 
    'R'     AS "R"
  )
)
 
-- 列转行
SELECT something, something
FROM some_table
UNPIVOT (
  count    FOR rating IN (
    "NC-17" AS 'NC-17', 
    "PG"    AS 'PG', 
    "G"     AS 'G', 
    "PG-13" AS 'PG-13', 
    "R"     AS 'R'
  )
)

10. 滥用 XML 和 JSON #

首先

JSON 只是更少特性和语法的 XML

现在,所有人都知道 XML 很棒。必然的结果是:

JSON 就逊多了

不要使用 JSON

现在我们已经解决了这一点,我们可以安全地忽略正在进行的 JSON 数据库中的炒作(你们大多数将在五年后遗憾),并继续到最后的例子。 如何在数据库中做XML。

给定一个原始的 XML 文档,我们想要解析他,不要在每个演员中嵌套逗号分隔的电影列表,在一个表中产生演员/电影的无范式表示。

开始,我们接下来要写三个 CTE

第一个,我们简单的解析 XML。下面使用 PostgreSQL:

n3xt-test=# WITH RECURSIVE
  x(v) AS (SELECT '
<actors>
  <actor>
    <first-name>Bud</first-name>
    <last-name>Spencer</last-name>
    <films>God Forgives... I Don’t, Double Trouble, They Call Him Bulldozer</films>
  </actor>
  <actor>
    <first-name>Terence</first-name>
    <last-name>Hill</last-name>
    <films>God Forgives... I Don’t, Double Trouble, Lucky Luke</films>
  </actor>
</actors>'::xml)
SELECT *
FROM x;

简单。

然后,我们使用 XPATH 来提取 XML 结构中的每一个值,并把它放到字段中:

n3xt-test=# WITH RECURSIVE
  x(v) AS (SELECT '...'::xml), 
  actors(actor_id, first_name, last_name, films) AS (
    SELECT
      row_number() OVER (),
      (xpath('//first-name/text()', t.v))[1]::TEXT,
      (xpath('//last-name/text()' , t.v))[1]::TEXT,
      (xpath('//films/text()'     , t.v))[1]::TEXT
    FROM unnest(xpath('//actor', (SELECT v FROM x))) t(v)
  )
SELECT *
FROM actors;
 actor_id | first_name | last_name |                              films
----------+------------+-----------+------------------------------------------------------------------
        1 | Bud        | Spencer   | God Forgives... I Don’t, Double Trouble, They Call Him Bulldozer
        2 | Terence    | Hill      | God Forgives... I Don’t, Double Trouble, Lucky Luke
(2 rows)

同样很简单。

最后,使用递归正则表达式模式匹配,然后就结束教程吗^_^

n3xt-test=# WITH RECURSIVE
  x(v) AS (SELECT '...'::xml),
  actors(actor_id, first_name, last_name, films) AS (...),
  films(actor_id, first_name, last_name, film_id, film) AS (
    SELECT actor_id, first_name, last_name, 1,
      regexp_replace(films, ',.+', '')
    FROM actors
    UNION ALL
    SELECT actor_id, a.first_name, a.last_name, f.film_id + 1,
      regexp_replace(a.films, '.*' || f.film || ', ?(.*?)(,.+)?', '\1')
    FROM films AS f
    JOIN actors AS a USING (actor_id)
    WHERE a.films NOT LIKE '%' || f.film
  )
SELECT *
FROM films;
 actor_id | first_name | last_name | film_id |          film
----------+------------+-----------+---------+-------------------------
        1 | Bud        | Spencer   |       1 | God Forgives... I Don’t
        2 | Terence    | Hill      |       1 | God Forgives... I Don’t
        1 | Bud        | Spencer   |       2 | Double Trouble
        2 | Terence    | Hill      |       2 | Double Trouble
        1 | Bud        | Spencer   |       3 | They Call Him Bulldozer
        2 | Terence    | Hill      |       3 | Lucky Luke
(6 rows)

开始总结陈词:

解语 #

这文章中所有的内容都是声明式的。并且相对简单。当然,我尝试然这篇文章更有趣味性,一切夸张的 SQL 被采用,并让大部分东西都比较易学。也不是都很简单,你必须多练习写 SQL。和其他语言一样,但是稍微难度大一点,因为:

  1. 时不时出现比较晦涩的语法
  2. 使用声明式的思想并不太容易。至少,和面向对象和面向过程式的思想差异很大。

但是一旦你习惯了,使用 SQL声明式编程 还是值得你学习的。您可以通过描述要从数据库获取的结果,在非常少的代码中表达您的数据之间的复杂关系。

是不是很棒?

附录-1: 随机生成用户登录行为: #

  1 CREATE TABLE user_login AS
  2 WITH RECURSIVE users(id) AS (
  3     SELECT 1
  4     UNION ALL
  5     SELECT id + 1
  6     FROM users WHERE id <= 20
  7 )
  8 SELECT u.id,  login_time.login_time
  9 FROM
 10 (SELECT id FROM users) u,
 11 LATERAL(
 12     SELECT login_date.*, login_time.*
 13     FROM
 14     (SELECT date(generate_series(now() - '10 days'::INTERVAL, now(), '1 day')) login_date, (random()*6)::int login_per_day) login_date,
 15     LATERAL(
 16         SELECT * FROM
 17         generate_series(login_date.login_date, login_date.login_date + '1 days'::INTERVAL, '1 hour') login_time
 18         ORDER BY random() LIMIT login_date.login_per_day
 19     ) login_time
 20 ) login_time
 21 ORDER BY login_time DESC;

附录-2: 生成订单数据: #

1 CREATE TABLE orders AS
2 SELECT *, round((100-random()*200)::NUMERIC, 2) amount
3 FROM generate_series(1, 20) id;

参考文献: