SQL 聚合中的 FILTER、CTE 与 CASE WHEN

熟悉 SQL 的开发者知道,同一个结果往往有多种实现。本教程使用三种方式生成一份常见报表。根据场景,专家可能选择其中任何一种;总体建议是选择最容易阅读的写法。

首先使用公共表表达式 CTE,它很适合组织子查询。然后在聚合中使用 CASE,筛选所需数值。最后使用 FILTER 简化语法,实现与 CASE 基本相同的工作。三者都能完成任务,但本例中 FILTER 更清晰。

我们要从 invoices 表生成月度收入报表,目标结果类似:

    mnth    | billed  | uncollected |  collected
------------+-----------------------+--------------+--
 2023-02-01 | 1498.06 |     1498.06 |           0
 2023-01-01 | 2993.95 |     1483.04 |      1510.91
 2022-12-01 | 1413.17 |      382.84 |      1030.33
 2022-11-01 | 1378.18 |      197.52 |      1180.66
 2022-10-01 | 1342.91 |      185.03 |      1157.88
 2022-09-01 | 1299.90 |       88.01 |      1211.89
 2022-08-01 | 1261.97 |       85.29 |      1176.68

开始前,关闭扩展显示。扩展显示适合阅读单条记录,但聚合结果以表格展示更清楚:

\x

invoices 表

先查看底层数据与表结构:

 \d invoices

输出:


                                              Table "public.invoices"
       Column       |              Type              | Collation | Nullable |               Default
--------------------+--------------------------------+-----------+----------+--------------------------------------
 id                 | bigint                         |           | not null | nextval('invoices_id_seq'::regclass)
 account_id         | integer                        |           |          |
 net_total_in_cents | integer                        |           |          |
 invoice_period     | daterange                      |           |          |
 status             | text                           |           |          |
 created_at         | timestamp(6) without time zone |           | not null |
 updated_at         | timestamp(6) without time zone |           | not null |
Indexes:
    "invoices_pkey" PRIMARY KEY, btree (id)
    "index_invoices_on_account_id" btree (account_id)

这里有两个设计选择:

  • invoice_period 使用日期范围。下界是账期开始,上界是结束。使用 lower(invoice_period) 提取起始值。
  • net_total_in_cents 以分为单位,而不是元,并保存为整数而非浮点数,避免出现不足一分的小数金额。

查看几条记录:

 SELECT * FROM invoices LIMIT 10;

返回:

 id | account_id | net_total_in_cents |     invoice_period      | status |         created_at         |         updated_at
----+------------+--------------------+-------------------------+--------+----------------------------+----------------------------
  1 |          4 |               1072 | [2022-02-01,2022-02-28) | paid   | 2023-02-01 17:28:37.420197 | 2023-02-01 17:28:37.420197
  2 |          6 |                955 | [2022-02-01,2022-02-28) | paid   | 2023-02-01 17:28:37.422361 | 2023-02-01 17:28:37.422361
  3 |          7 |                322 | [2022-02-01,2022-02-28) | paid   | 2023-02-01 17:28:37.423726 | 2023-02-01 17:28:37.423726
  4 |          9 |                778 | [2022-02-01,2022-02-28) | paid   | 2023-02-01 17:28:37.425423 | 2023-02-01 17:28:37.425423
  5 |         10 |                338 | [2022-02-01,2022-02-28) | paid   | 2023-02-01 17:28:37.426731 | 2023-02-01 17:28:37.426731
  6 |         12 |                386 | [2022-02-01,2022-02-28) | paid   | 2023-02-01 17:28:37.428382 | 2023-02-01 17:28:37.428382
  7 |         16 |                 66 | [2022-02-01,2022-02-28) | paid   | 2023-02-01 17:28:37.430613 | 2023-02-01 17:28:37.430613
  8 |         18 |                283 | [2022-02-01,2022-02-28) | paid   | 2023-02-01 17:28:37.432194 | 2023-02-01 17:28:37.432194
  9 |         25 |                508 | [2022-02-01,2022-02-28) | paid   | 2023-02-01 17:28:37.43536  | 2023-02-01 17:28:37.43536
 10 |         26 |                746 | [2022-02-01,2022-02-28) | paid   | 2023-02-01 17:28:37.436662 | 2023-02-01 17:28:37.436662
(10 rows)

使用 CTE

先看公共表表达式,也常称为 WITH 子句。

下面看起来复杂,但可以理解为把三个不同查询组织为一个。totals、collected 和 invoiced 分别计算每个月的对应值,最终再按 mnth 连接:

 WITH totals AS (
	SELECT
	  lower(i.invoice_period) as mnth,
	  SUM(i.net_total_in_cents) / 100.0 as net_total
	FROM invoices i
	GROUP BY 1
), collected AS (
	SELECT
	  lower(i.invoice_period) as mnth,
	  SUM(i.net_total_in_cents) / 100.0 as amount
	FROM invoices i
	WHERE status = 'paid'
	GROUP BY 1
), invoiced AS (
	SELECT
	  lower(i.invoice_period) AS mnth,
	  sum(i.net_total_in_cents) / 100.0 AS amount
	FROM invoices i
	WHERE status = 'invoiced'
	GROUP BY 1
)

SELECT totals.mnth,
       totals.net_total as billed,
       COALESCE(invoiced.amount, 0) as uncollected,
       COALESCE(collected.amount, 0) as collected
FROM totals
	LEFT JOIN invoiced ON totals.mnth = invoiced.mnth
	LEFT JOIN collected on totals.mnth = collected.mnth
ORDER BY 1 desc;

输出每个月一条记录,包含发票总金额、未收金额和已收金额。可以拆出各个 WITH 子查询单独运行,观察其结果,帮助理解整体 SQL。

用 CASE 进行条件筛选

另一种方法是在聚合中使用 CASE 按条件筛选。本例中的查询比 CTE 版本短得多:

 SELECT LOWER(i.invoice_period) as mnth,
       SUM(i.net_total_in_cents) / 100.0 as billed,
       SUM (CASE WHEN status = 'invoiced' THEN  i.net_total_in_cents END) / 100.00 as uncollected,
       SUM (CASE WHEN status = 'paid' THEN  i.net_total_in_cents END) / 100.00 as collected
FROM invoices i
GROUP BY 1
ORDER BY 1 desc;

可以将 CASE 看成 IF/THEN:条件满足时,将 net_total_in_cents 交给 SUM。此类 CASE 在数据分析中很常见,但还有 FILTER 这个选项。

使用 FILTER

FILTER 的作用与 CASE 类似,但 SQL 更容易阅读:

 SELECT LOWER(i.invoice_period) as mnth,
       SUM (i.net_total_in_cents) / 100.0 as billed,
       SUM (i.net_total_in_cents) FILTER (WHERE status = 'invoiced') / 100.00 as uncollected,
       SUM (i.net_total_in_cents) FILTER (WHERE status = 'paid') / 100.00 as collected
FROM invoices i
GROUP BY 1
ORDER BY 1 desc;

如果希望 Postgres 语句尽可能清晰、简洁,FILTER 是值得掌握的工具。


原文:Using FILTER vs CTEs and CASE WHEN。作者/维护方:Crunchy Data 教程团队。本文为中文翻译,代码及命令保留原文。

原站版权声明:© 2018–2026 Crunchy Data Solutions, Inc.

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容