这样理解关联是不是耳目一新

SQL 对关联的定义过于简单,关联就是两个表做笛卡尔积后再过滤,在语法上写成 A JOIN B ON …的形式。Python 关联基本上是延用 SQL 的方式,概念和方法都差不多。

esProc SPL 却不是这么理解关联的,其定义不再和笛卡尔积有关。

SPL 把关联分成两种情况。一种叫外键关联,是指一个表的普通字段(外键)和另一个表的主键关联。

比如订单表的外键 employee_id 和 customer_id 分别关联客户表、雇员表的主键,现在要找出客户城市是纽约,雇员国籍是美国的订单。

说明一下,这里说的是逻辑上的主键,也就是在表中取值唯一的字段,不一定是在物理表上建立的 primary key。外键也不一定是物理上的 foreign key。

SQL 这样写:

select o.order_id, o.order_date, o.employee_id, o.customer_id
from orders o
    join employees e on o.employee_id = e.employee_id
    join customers c on o.customer_id = c.customer_id
where c.city = 'New York' and e.nationality = 'US';

两个关联都用 join。

Python 这样写:

merged=orders.merge(employees,on='employee_id').merge(customers,on='customer_id')
filter_result = merged[(merged['city'] == 'New York') & (merged['nationality'] == 'US')]
result = filter_result[['order_id', 'order_date', 'employee_id', 'customer_id']]

两个关联都用 merge。

SPL 则把外键理解成一个对象。因为外键在关联表中是主键,具有唯一性。也就是说,外键可以唯一对应关联表的记录(对象),它可以直接取值为这个记录,而关联表的字段则可以理解为这个对象的属性。比如订单表的 employee_id 可以理解为雇员表中的一个记录(对象),这个记录的字段 employee_name 和 nationality,都是这个对象的属性。

这个问题用 SPL 这样写:

orders.switch(employee_id,employee:employee_id;customer_id,customer:customer_id)
result = orders.select(customer_id.city == "New York" && employee_id.nationality=="US")

switch 函数把订单表的 employee_id 和 customer_id 转换成雇员表、客户表对应的记录,这两个字段就变成了我们想要的对象。

接下来的计算就非常简单了,employee_id 对象取属性 city,customer 取属性 nationality,写成过滤条件即可。

这种对象式的理解方式显然比笛卡尔积过滤的理解方式要自然直观得多,让人耳目一新。

SPL 的另一种关联叫主键关联,是指用一个表的主键,关联另一个表的主键或部分主键。

比如订单表和订单明细表,关联字段 order_id 是订单表的主键。而 order_id 和 product_id 共同组成订单明细表的主键。现在要取出订单号、订单运费和订单金额,SQL 这样写:

select o.order_id,o.shipping_fee,sum(od.amount) as order_amount
from orders o 
    join order_details od on o.order_id = od.order_id
group by o.order_id, o.shipping_fee;

SQL 每次 join 之后必须拼出字段,算出结果集,下次用到关联结果时要重新计算 join。例如现在还要查出明细数量大于 5 的订单。SQL 必须再写一条语句,把关联的代码重写一遍:

select o.order_id,o.order_date,count(od.product_id)
from orders o
    join order_details od on o.order_id = od.order_id
group by o.order_id, o.order_date
having count(od.product_id)>5;

SPL 则把主键关联看作记录对象或记录集合之间的关联,要取出订单号、订单运费和订单金额这样写代码:

od_group=order_details.group(order_id)
order_all=join(orders:o,order_id;od_group:od,order_id)
result1=order_all.new(o.order_id,o.order_date,od.sum(amount))

group 函数把订单明细按 order_id 分组,得到集合的集合 od_group,其成员是 order_id 相同记录组成的子集合。这些子集之间 order_id 是不重复的,也是逻辑主键。

join 函数找出 orders 和 od_group 中主键相同的成员,作为两个字段 o 和 od,组合成一条新记录放到结果表 order_all 中。

注意这里,SPL 中的关联不一定是两个记录构成的集合(即表)之间进行,它还允许集合构成的集合参与关联。

这样,主键关联变得更自然直观、容易理解,而且关联结果还可以重复使用,继续完成多种计算。比如还要查出明细数量大于 5 的订单,可以在 SPL 代码的基础上继续写:

result2=order_all.new(o.order_id,o.order_date,od.count(product_id):count).select(count>5)

SPL 关联运算的效果在关联结果仅使用一次时并不明显,但多次使用时代码就会明显简化。

Python 也可以复用关联的结果,代码是这样:

merged = pd.merge(orders, order_details, on='order_id')
result1=merged.groupby(['order_id','order_date'])['amount'].sum()

grouped=merged.groupby(['order_id','order_date'])['product_id'].count().reset_index(name='product_count')
result2 = grouped[grouped['product_count'] > 5]

代码也比 SQL 简单。

但是,Python 的关联结果 merged 是两个表字段拼接成的宽表,灵活性较差,用来简单分组汇总没问题,但需求稍复杂些,继续计算就变得麻烦了。

比如现在还要找到包含牛肉产品的订单,并求订单金额。Python 继续写代码:

beef_order_ids = merged[merged['product_name'] == 'beef']['order_id'].unique()
beef_orders = merged[merged['order_id'].isin(beef_order_ids)]
result3=beef_orders.groupby('order_id').agg({'order_date':'first','amount': 'sum'}).reset_index()

要计算条件过滤、去重、再用 in 过滤,再分组汇总,思路和代码都很繁琐。

关联结果采用宽表形式,继续计算时还很容易出错。如果程序员不仔细分析,很可能会直接从宽表 merged 中过滤出牛肉产品的记录,然后分组汇总得到结果。但是,这些订单中其他产品的金额也被过滤掉了,结果当然是错的。

SPL 主键关联的结果是一个嵌套表结构,字段 od 是一个表对象,可以很灵活的继续进行各种计算。

我们可以在 SPL 代码的基础上继续写:

result3=order_all.select(od.select@1(product_name=="beef")).new(o.order_id,o.order_date,od.sum(amount))

其中,od.select@1 是取出 od 表中第一条满足条件的记录。如果找不到就返回 null(相当于 false),这条 o 和 od 组成的记录会被 order_all.select 过滤掉。

实际上,SPL 关联是把数据组成了符合业务关系的样子,可以继续写出非常简洁的代码完成复杂计算。

而且,SPL 的关联运算也不容易出错。这里 od 字段是一个整体,order_all.select 过滤时,od 只会全保留或全丢弃,其中的明细记录不可能被部分过滤掉,也就不会出现 Python 那样的错误了。

还有一些涉及关联的情况,SQL 和 Python 也都很容易出错。比如:订单表和订单明细、回款明细关联,求累计回款金额小于订单总金额的 order_id、order_date。

如果不仔细分析,SQL 在前面订单关联订单明细的代码基础上可能会写成这样:

select o.order_id,o.order_date
from orders o
    join order_details od on o.order_id = od.order_id
    join payment_details pd on o.order_id = pd.order_id
group by o.order_id,o.order_date
having sum(pd.payment_amount) < sum(od.amount);

但这样计算的结果是错的,回款明细和订单明细出现了多对多关联。比如某订单有两条订单明细,一条回款明细。这样 join 的结果中,这个订单会有两条记录,回款金额会多出现一次,汇总结果当然是错的。

SQL 必须写子查询,先 group 再关联才对:

select o.order_id,o.order_date
from orders o
join (
    select order_id, sum(amount) as o_amount
    from order_details
    group by order_id
) od on o.order_id = od.order_id
join (
    select order_id, sum(payment_amount) as p_amount
    from payment_details
    group by order_id
) pd on o.order_id = pd.order_id
where pd.p_amount < od.o_amount;

Python 也可能写成先关联三个表,再分组:

joined_orders_details = orders.merge(order_details, on='order_id')
joined_all = joined_orders_details.merge(payment_details, on='order_id')
grouped = joined_all.groupby('order_id').agg({
      'order_date': 'first',
        'amount': 'sum',
        'payment_amount': 'sum'
    })
result = grouped[grouped['payment_amount'] < grouped['amount']][['order_id', 'order_date']]

其中,joined_orders_details 和 payment_details 也出现了多对多关联,结果同样是错的。

如果改成先分组汇总再关联的话,计算结果是对了,但关联结果复用会出现问题。由于关联结果中订单明细和回款明细已经被汇总,没有了详细数据,很多计算都无法继续进行了。

SPL 的主键关联运算结果中,回款明细集合和订单明细集合都是字段,不会出现多对多关联的错误:

od_group=order_details.group(order_id)
pd_group=payment_details.group(order_id)
orders_all=join(orders:o,order_id;od_group:od,order_id;pd_group:pd,order_id)
orders_all.select(pd.sum(payment_amount)< od.sum(amount))

再继续复用关联结果,进行其他计算也没有问题。

小结一下,SQL 把关联运算都定义成笛卡尔积再过滤,看似简单,但没有体现关联运算的本质,复杂情况不易理解,还容易出错,而且关联结果不能复用。Python 的关联运算基本上延用了 SQL 方式,略有优势之处是关联结果可复用了,但这个结果是个宽表,很不灵活,继续做其他计算会很麻烦,也容易出错。SPL 则摒弃了笛卡尔积的思路,重新定义关联运算,体现了关联运算的本质,让人耳目一新。关联结果是可重复使用的嵌套表,非常灵活,能继续用简洁的代码完成各种复杂计算,还能降低错误率。