【问题标题】:SQL: order by, then select first row with distinct value for multiple columnsSQL:排序,然后为多列选择具有不同值的第一行
【发布时间】:2016-04-18 03:13:55
【问题描述】:

作为一个简化的示例,我需要选择客户的送货地址不同于他们之前的送货地址的每个实例。所以我有一个大表,其中包含以下列:

purchase_id | cust_id | date | address  | description
-----------------------------------------------------------
 1          | 5       | jan  | address1 | desc1
 2          | 6       | jan  | address2 | desc2
 3          | 5       | feb  | address1 | desc3
 4          | 6       | feb  | address2 | desc4
 5          | 5       | mar  | address3 | desc5
 6          | 5       | mar  | address3 | desc6
 7          | 5       | apr  | address1 | desc7
 8          | 6       | may  | address4 | desc8

请注意,客户可以像客户 5 在第 7 行中所做的那样“移回”到以前的地址。

我想要选择的内容(并且尽可能高效,因为这是一个非常大的表格)是每个“块”中的第一行,其中客户将后续订单运送到同一地址。在此示例中,这将是第 1、2、5、7 和 8 行。在所有其他行中,客户的地址与其之前的订单相同。

所以我想先ORDER BY (cust_id, date),然后SELECT purchase_id, cust_id, min(date), address, description

但是我遇到了麻烦,因为 SQL 通常要求在 ORDER BY 之前完成 GROUP BY。因此,我无法弄清楚如何适应例如this question 的最佳答案之一(否则我非常喜欢。)有必要(至少在概念上)在分组或使用像 min() 这样的聚合函数之前按日期排序,否则我会错过像第 7 行这样的实例我的示例表,其中客户“搬回”到以前的地址。

还要注意两个客户可以共享一个地址,所以我需要在按日期订购后有效地按cust_idaddress 分组。

我使用的是雪花,我相信它与最新版本的 PostgreSQL 和 SQL Server 具有大部分相同的命令(尽管我对雪花还很陌生,所以不能完全确定。)

【问题讨论】:

  • 您是否只想为拥有多个地址的客户退货?
  • 可能是 1,2,5,8 而不是 1,2,7,8?
  • Anthony E:不,我想(至少)为所有曾经有过地址的客户返回 1 行,为已经更改过一次或多次地址的客户返回更多行。 Giorgi Nakeuri:谢谢,应该是 1、2、5、7 和 8。(第 5 行和第 7 行的地址都与客户使用的最后一个地址不同。)已编辑。

标签: sql group-by sql-order-by snowflake-cloud-data-platform


【解决方案1】:

抱歉回复晚了。几天前我打算对这篇文章做出反应。

我能想到的“最合适”的方法是使用 LAG 函数。

拿着这个:

select purchase_id, cust_id, address, 
lag(address, 1) over (partition by cust_id order by purchase_id) prev_address 
from x order by cust_id, purchase_id;
-------------+---------+----------+--------------+
 PURCHASE_ID | CUST_ID | ADDRESS  | PREV_ADDRESS |
-------------+---------+----------+--------------+
 1           | 5       | address1 | [NULL]       |
 3           | 5       | address1 | address1     |
 5           | 5       | address3 | address1     |
 6           | 5       | address3 | address3     |
 7           | 5       | address1 | address3     |
 2           | 6       | address2 | [NULL]       |
 4           | 6       | address2 | address2     |
 8           | 6       | address4 | address2     |
-------------+---------+----------+--------------+

然后您可以轻松地检测带有您描述的事件的行

select purchase_id, cust_id, address, prev_address from (
  select purchase_id, cust_id, address, 
  lag(address, 1) over (partition by cust_id order by purchase_id) prev_address 
  from x 
) sub 
where not equal_null(address, prev_address)
order by cust_id, purchase_id;
-------------+---------+----------+--------------+
 PURCHASE_ID | CUST_ID | ADDRESS  | PREV_ADDRESS |
-------------+---------+----------+--------------+
 1           | 5       | address1 | [NULL]       |
 5           | 5       | address3 | address1     |
 7           | 5       | address1 | address3     |
 2           | 6       | address2 | [NULL]       |
 8           | 6       | address4 | address2     |
-------------+---------+----------+--------------+

请注意,我使用 EQUAL_NULL 函数来获得 NULL=NULL 语义。

请注意,尽管 LAG 函数可能是计算密集型的(但与之前提出的使用 ROW_NUMBER 相当)

【讨论】:

  • ?LAG 绝对可以胜任。虽然我更喜欢添加子组的方法,但以前我必须使用两个步骤 cte:LAG 和条件窗口 SUM 来实现它。现在有了 Snowflake 的 CONDITIONAL_CHANGE_EVENT,这很容易:)
  • 好吧,我会在这里使用我“过度使用”的朋友 QUALIFY 来避免子查询,但今天我学会了 EQUAL_NULL
  • 我发现 LAG/LEAD 是最便宜的 WINDOW 函数之一,因此不同意计算成本高,尤其是关于 ROW_NUMBER 的自连接。
【解决方案2】:

您可以使用row_number 窗口函数来解决问题:

;with cte as(select *, row_number() over(partition by cust_id, address
                                         order by purchase_id) as rn from table)
select * from cte 
where rn = 1

【讨论】:

  • 感谢 Giorgi Nakeuri,这行得通。我知道 row_number(),但没有意识到我可以对多个字段进行分区并获得所需的结果。
  • 我不确定这是一个正确的答案。它没有找到第 7 行,因为它具有相同的 cust_id 和地址。
【解决方案3】:

Snowflake 引入了 CONDITIONAL_CHANGE_EVENT,可以理想地解决描述的情况:

当当前行中参数 expr1 的值与前一行中 expr1 的值不同时,返回窗口分区中每一行的窗口事件编号。 窗口事件编号开始从 0 开始,并以 1 为增量,以指示到目前为止该窗口内的更改次数


数据准备:

CREATE OR REPLACE TABLE t(purchase_id INT, cust_id INT,
                          date DATE, address TEXT, description TEXT);

INSERT INTO t(purchase_id, cust_id, date, address, description)
VALUES 
 ( 1, 5, '2021-01-01'::DATE ,'address1','desc1')
,( 2, 6, '2021-01-01'::DATE ,'address2','desc2')
,( 3, 5, '2021-02-01'::DATE ,'address1','desc3')
,( 4, 6, '2021-02-01'::DATE ,'address2','desc4')
,( 5, 5, '2021-03-01'::DATE ,'address3','desc5')
,( 6, 5, '2021-03-01'::DATE ,'address3','desc6')
,( 7, 5, '2021-04-01'::DATE ,'address1','desc7')
,( 8, 6, '2021-05-01'::DATE ,'address4','desc8');

查询:

SELECT *, 
 CONDITIONAL_CHANGE_EVENT(address) OVER (PARTITION BY CUST_ID ORDER BY DATE) AS CCE
FROM t
ORDER BY purchase_id;

一旦确定了子组:CCE 列,就可以使用 QUALIFY 来查找每个 CUST_ID, CCE 的第一行。

完整查询:

WITH cte AS (
 SELECT *,
  CONDITIONAL_CHANGE_EVENT(address) OVER (PARTITION BY CUST_ID ORDER BY DATE) AS CCE
 FROM t
)
SELECT *
FROM  cte
QUALIFY ROW_NUMBER() OVER(PARTITION BY CUST_ID, CCE ORDER BY DATE) = 1
ORDER BY purchase_id;

输出:

【讨论】:

    【解决方案4】:

    这可能最好通过子查询来解决,以获取每个用户的第一次购买,然后使用IN 根据该结果过滤行。

    澄清一下,purchase_id 是一个自动增量列,对吗?如果是这样,则必须在以后创建具有更高 purchase_id 的购买,并且以下内容就足够了:

    SELECT *
    FROM purchases
    WHERE purchase_id IN (
      SELECT MIN(purchase_id) AS first_purchase_id
      FROM purchases
      GROUP BY cust_id
    )
    

    如果您只想为拥有多个地址的客户首次购买,请在子查询中添加 HAVING 子句:

    SELECT *
    FROM purchases
    WHERE purchase_id IN (
      SELECT MIN(purchase_id) AS first_purchase_id
      FROM purchases
      GROUP BY cust_id
      HAVING COUNT(DISTINCT address) > 1
    )
    

    小提琴:http://sqlfiddle.com/#!9/12d75/6

    但是,如果 purchase_id 不是自动增量列,则在您的子查询上同时在 cust_idmin(date) 上使用 SELECT,并在 cust_idmin(date) 上使用 INNER JOIN

    SELECT *
    FROM purchases
    INNER JOIN (
      SELECT cust_id, MIN(date) AS min_date
      FROM purchases
      GROUP BY cust_id
      HAVING COUNT(DISTINCT address) > 1
    ) cust_purchase_date
    ON purchases.cust_id = cust_purchase_date.cust_id AND purchases.date = cust_purchase_date.min_date
    

    但是,第一个查询示例可能会更快,所以如果您 purchase_id 是一个自动增量列,请使用它。

    【讨论】:

    • 感谢 Anthony,但这不会为每个客户返回每个后续的“新”地址。我不只是希望每个用户的第一次购买;我希望每个第一次购买的收货地址与之前的收货地址不同。
    【解决方案5】:

    还有更多迟来的选择/意见:

    鉴于这是一种边缘检测,LAG/LEAD(取决于您要查找的边缘)是最简单的工具。

    Marcin 的LAG 选项可以通过QUALIFY 从子选择移动到第一级选项。

    • NOT 和EQUAL_NULL 相加的地方是,如果有一个空地址,那么第一个 LAG 也将返回空,这将不相等,并且在翻转时变为真。所以 EQUAL_NULL 安全比较可以很好地捕捉到这一点。
    SELECT * 
    FROM data_table 
    QUALIFY not equal_null(address, lag(address) over(partition by cust_id order by purchase_id))
    ORDER BY 1
    

    给予:

    PURCHASE_ID CUST_ID DATE ADDRESS DESCRIPTION
    1 5 2021-01-01 address1 desc1
    2 6 2021-01-01 address2 desc2
    5 5 2021-03-01 address3 desc5
    7 5 2021-04-01 address1 desc7
    8 6 2021-05-01 address4 desc8

    Lukasz 的 CONDITIONAL_CHANGE_EVENT 是一个非常好的解决方案,但是 CONDITIONAL_CHANGE_EVENT 不仅是寻找一个变化边缘,而且是枚举它们,所以如果我们正在寻找第 5 个变化或类似的变化,那么 CONDITIONAL_CHANGE_EVENT 可以让您不必将 LAG/LEAD 与ROW_NUMBER()。因此,您不能将该解决方案折叠成一个块:

    喜欢:

     ROW_NUMBER() OVER(PARTITION BY CUST_ID, CONDITIONAL_CHANGE_EVENT(address) OVER (PARTITION BY CUST_ID ORDER BY DATE) ORDER BY DATE) = 1
    

    因为 CONDITIONAL_CHANGE_EVENT 中隐含的 row_number 会产生错误:

    窗口函数 x 不能嵌套在另一个窗口函数中。

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 2021-11-05
      • 1970-01-01
      • 2015-07-17
      • 1970-01-01
      • 2021-10-04
      • 1970-01-01
      • 2017-12-24
      相关资源
      最近更新 更多