连接运算 3-JOIN 的简化

连接运算 2- 等值 JOIN 的剖析

三. JOIN 的简化

我们先看如何利用关联都涉及主键这个特征来简化 JOIN 的代码书写,分别讨论这三种情况。

1. 外键属性化

先看个例子,设有如下两个表:

employee 员工表

  • id 员工编号

  • name 姓名

  • nationality 国籍

  • department 所属部门

department 部门表

  • id 部门编号

  • name 部门名称

  • manager 部门经理

employee 表和 delpartment 表的主键都是其中的 id 字段,employee 表的 department 字段是指向 department 表的外键,department 表的 manager 字段又是指向 employee 表的外键(因为经理也是个员工)。这是很常规的表结构设计。

现在我们想问一下:哪些美国籍员工有一个中国籍经理?

用 SQL 写出来是个三表 JOIN 的语句:

SELECT A.*
FROM employee A
JOIN department B ON A.department=B.id
JOIN employee C ON B.manager=C.id
WHERE A.nationality='USA' AND C.nationality='CHN'

首先要 FROM employee 用于获取员工信息,然后这个 employee 表要和 department 做 JOIN 获取员工的部门信息,接着这个 department 表还要再和 employee 表 JOIN 要获取经理的信息,这样 employee 表需要两次参与 JOIN,在 SQL 语句中要为它起个别名加以区分,整个句子就显得比较复杂难懂。

如果我们把外键字段直接理解成它关联的维表记录,就可以换一种写法:

SELECT * 
FROM employee
WHERE nationality='USA' AND department.manager.nationality='CHN'

当然,这不是标准的 SQL 语句了。

第二个句子中有部分表示当前员工的“所属部门的经理的国籍”。我们把外键字段理解成维表的记录后,维表的字段被理解为外键的属性,department.manager 即是”所属部门的经理“,而这个字段在 department 中仍然是个外键,那么它对应的维表记录字段可以继续理解为它的属性,也就会有 department.manager.nationality,即“所属部门的经理的国籍”。

这种对象式的理解方式称为外键属性化,显然比笛卡尔积过滤的理解方式要自然直观得多。外键表 JOIN 时并不会涉及到两个表的乘法,外键字段只是用于找到维键表中对应的那条记录,完全不会涉及到笛卡尔积这种有乘法特性的运算。

我们前面约定,外键关联时时维表中关联键必须是主键,这样,事实表中每一条记录的外键字段关联的维表记录就是唯一的,也就是说 employee 表中每一条记录的 deparment 字段唯一关联一条 deparment 表中的记录,而 department 表中每一条记录的 manager 字段也唯一关联一条 employee 表中的记录。这就保证了对于 employee 表中的每一条记录,deparment.manager.nationality 都有唯一的取值,可以被明确定义。

但是,SQL 对 JOIN 的定义中并没有主键的约定,如果基于 SQL 的规则,就不能认定与事实表中外键关联的维表记录有唯一性,有可能发生与多条记录关联,对于 employee 表的记录来讲,department.manager.nationality 没有明确定义,就不能使用了。

事实上,这种对象式写法在高级语言(如 C,Java)中很常见,在这类语言中,数据就是按对象方式存储的。employee 表中的 department 字段取值根本就是一个对象,而不是编号。其实许多表的主键取值本身并没有业务意义,仅仅是为了区分记录,而外键字段也仅仅是为了找到维表中的相应记录,如果外键字段直接是对象,就不需要再通过编号来标识了。不过,SQL 不能支持这种存储机制,还要借助编号。

我们说过外键关联是不对称的,即事实表和维表是不对等的,只能基于事实表去找维表字段,而不会有倒过来的情况。

2. 同维表等同化

同维表的情况相对简单,还是从例子开始,设有两个表:

employee 员工表

  • id 员工编号

  • name 姓名

  • salary 工资

manager 经理表

  • id 员工编号

  • allowance 岗位津贴

  • ….

两个表的主键都是 id,经理也是员工,两表共用同样的员工编号,经理会比普通员工多一些属性,另用一个经理表来保存。

现在我们要统计所有员工(包括经理)的总收入(加上津贴)。

用 SQL 写出来还是会用到 JOIN:

SELECT employee.id, employee.name, employy.salary+manager.allowance
FROM employyee
LEFT JOIN manager ON employee.id=manager.id

而对于两个一对一的表,我们其实可以简单地把它们看成一个表:

SELECT id,name,salary+allowance
FROM employee

类似地,根据我们的约定,同维表 JOIN 时两个表都是按主键关联的,相应记录是唯一对应的,salary+allowance 对 employee 表中每条记录都是唯一可计算的,不会出现歧义。这种简化方式称为同维表等同化。

同维表之间的关系是对等的,从任何一个表都可以引用到其它同维表的字段。

3. 子表集合化

订单及订单明细是典型的主子表:

Orders 订单表

  • id 订单编号

  • customer 客户

  • date 日期

OrderDetail 订单明细

  • id 订单编号

  • no 序号

  • product 订购产品

  • price 价格

Orders 表的主键是 id,OrderDetail 表中的主键是 (id,no),前者的主键是后者的一部分。

现在我们想计算每张订单的总金额。

用 SQL 写出来会是这样:

SELECT Orders.id, Orders.customer, SUM(OrderDetail.price)
FROM Orders
JOIN OrderDetail ON Orders.id=OrderDetail.id
GROUP BY Orders.id, Orders.customer

要完成这个运算,不仅要用到 JOIN,还需要做一次 GROUP BY,否则选出来的记录数太多。

如果我们把子表中与主表相关的记录看成主表的一个字段,那么这个问题也可以不再使用 JOIN 以及 GROUP BY:

SELECT id, customer, OrderDetail.SUM(price)
FROM Orders

与普通字段不同,OrderDetail 被看成 Orders 表的字段时,其取值将是一个集合,因为两个表是一对多的关系。所以要在这里使用聚合运算把集合值计算成单值。这种简化方式称为子表集合化。

这样看待主子表关联,不仅理解书写更为简单,而且不容易出错。

假如 Orders 表还有一个子表用于记录回款情况:

OrderPayment 订单回款表

  • id 订单编号

  • date 回款日期

  • amount 回款金额

  • ….

我们现在想知道那些订单还在欠钱,也就是累计回款金额小于订单总金额的订单。

简单地把这三个表 JOIN 起来是不对的,OrderDetail 和 OrderPayment 会发生多对多的关系,这就错了(回忆前面提过的多对多大概率错误的说法)。这两个子表要分别先做 GROUP,再一起与 Orders 表 JOIN 起来才能得到正确结果,会写成子查询的形式:

SELECT Orders.id, Orders.customer,A.x,B.y
FROM Orders
LEFT JOIN ( SELECT id,SUM(price) x FROM OrderDetail GROUP BY id) A
    ON Orders.id=A.id
LEFT JOIN ( SELECT id,SUM(amount) y FROM OrderPayment GROUP BY id ) B
    ON Orders.id=B.id
WHERE A.x>B.y

如果我们继续把子表看成主表的集合字段,那就很简单了:

SELECT id,customer,OrderDetail.SUM(price) x,OrderPayment.SUM(amount) y
FROM Orders
WHERE x>y

这种写法也不容易发生多对多的错误。

主子表关系是不对等的,不过两个方向的引用都有意义,上面谈了从主表引用子表的情况,从子表引用主表则和外键表类似。

我们改变对 JOIN 运算的看法,摒弃笛卡尔积的思路,把多表关联运算看成是稍复杂些的单表运算。这样,相当于把最常见的等值 JOIN 运算的关联消除了,甚至在语法中取消了 JOIN 关键字,书写和理解都要简单很多。

连接运算 4- 维度对齐