【问题标题】:SQL Query to Collapse Duplicate Values By Date Range按日期范围折叠重复值的 SQL 查询
【发布时间】:2010-10-16 16:39:18
【问题描述】:

我有一个具有以下结构的表:ID、月份、年份、值,每个 id 每月有一个条目的值,大多数月份具有相同的值。

我想为该表创建一个视图,该视图折叠以下相同的值:ID、开始月份、结束月份、开始年份、结束年份、值,每个值每个 ID 一行。

问题是,如果一个值发生变化然后又回到原来的值,它应该在表中有两行

所以:

  • 100 1 2008 80
  • 100 2 2008 80
  • 100 3 2008 90
  • 100 4 2008 80

应该产生

  • 100 1 2008 2 2008 80
  • 100 3 2008 3 2008 90
  • 100 4 2008 4 2008 80

当值返回原始值时,以下查询适用于除此特殊情况之外的所有情况。

select distinct id, min(month) keep (dense_rank first order by month) 
over (partition   by id, value) startMonth, 
max(month) keep (dense_rank first order by month desc) over (partition
by id, value) endMonth, 
value

数据库是Oracle

【问题讨论】:

  • 第一个结果集行中的 2 是因为它比下一次更改 (2008-03) 少一个月,还是因为它是相同值的最后日期 (2008-02)?如果数据中缺少月份,则存在细微差别。
  • 不会缺少任何月份,但您可以假设它是相同值的最后日期

标签: sql oracle


【解决方案1】:

我将逐步开发我的解决方案,将每个转换分解为一个视图。这既有助于解释正在做什么,也有助于调试和测试。它本质上是将功能分解的原理应用于数据库查询。

我也打算不使用 Oracle 扩展来完成它,使用应该在任何现代 RBDMS 上运行的 SQL。所以没有保留,过度,分区,只有子查询和分组。 (如果它不适用于您的 RDBMS,请在 cmets 中通知我。)

首先是表格,由于我没有创意,我将其称为month_value。由于 id 实际上不是唯一的 id,我将其称为“eid”。其他列是“m”onth、“y”ear 和“v”alue:

create table month_value( 
   eid int not null, m int, y int,  v int );

插入数据后,对于两个 eid,我有:

> select * from month_value;
+-----+------+------+------+
| eid | m    | y    | v    |
+-----+------+------+------+
| 100 |    1 | 2008 |   80 |
| 100 |    2 | 2008 |   80 |
| 100 |    3 | 2008 |   90 |
| 100 |    4 | 2008 |   80 |
| 200 |    1 | 2008 |   80 |
| 200 |    2 | 2008 |   80 |
| 200 |    3 | 2008 |   90 |
| 200 |    4 | 2008 |   80 |
+-----+------+------+------+
8 rows in set (0.00 sec)

接下来,我们有一个实体,即月份,它表示为两个变量。这应该是一列(日期或日期时间,或者甚至可能是日期表的外键),所以我们将其设为一列。我们将其作为线性变换进行,使其排序与 (y, m) 相同,并且对于任何 (y,m) 元组都有一个且唯一的值,并且所有值都是连续的:

> create view cm_abs_month as 
select *, y * 12 + m as am from month_value;

这给了我们:

> select * from cm_abs_month;
+-----+------+------+------+-------+
| eid | m    | y    | v    | am    |
+-----+------+------+------+-------+
| 100 |    1 | 2008 |   80 | 24097 |
| 100 |    2 | 2008 |   80 | 24098 |
| 100 |    3 | 2008 |   90 | 24099 |
| 100 |    4 | 2008 |   80 | 24100 |
| 200 |    1 | 2008 |   80 | 24097 |
| 200 |    2 | 2008 |   80 | 24098 |
| 200 |    3 | 2008 |   90 | 24099 |
| 200 |    4 | 2008 |   80 | 24100 |
+-----+------+------+------+-------+
8 rows in set (0.00 sec)

现在我们将在相关子查询中使用自联接来为每一行查找值发生变化的最早的后续月份。我们将这个视图基于我们之前创建的视图:

> create view cm_last_am as 
   select a.*, 
    ( select min(b.am) from cm_abs_month b 
      where b.eid = a.eid and b.am > a.am and b.v <> a.v) 
   as last_am 
   from cm_abs_month a;

> select * from cm_last_am;
+-----+------+------+------+-------+---------+
| eid | m    | y    | v    | am    | last_am |
+-----+------+------+------+-------+---------+
| 100 |    1 | 2008 |   80 | 24097 |   24099 |
| 100 |    2 | 2008 |   80 | 24098 |   24099 |
| 100 |    3 | 2008 |   90 | 24099 |   24100 |
| 100 |    4 | 2008 |   80 | 24100 |    NULL |
| 200 |    1 | 2008 |   80 | 24097 |   24099 |
| 200 |    2 | 2008 |   80 | 24098 |   24099 |
| 200 |    3 | 2008 |   90 | 24099 |   24100 |
| 200 |    4 | 2008 |   80 | 24100 |    NULL |
+-----+------+------+------+-------+---------+
8 rows in set (0.01 sec)

last_am 现在是值 v 发生变化的第一个(最早)月份(当前行的月份之后)的“绝对月份”。在表中没有针对该 eid 的较晚月份的情况下为 null。

由于在 v 发生变化(发生在 last_am)之前的所有月份中 last_am 都是相同的,我们可以对 last_am 和 v(当然还有 eid)进行分组,并且在任何组中,min(am)是具有该值的第一个连续月份的绝对月份:

> create view cm_result_data as 
  select eid, min(am) as am , last_am, v 
  from cm_last_am group by eid, last_am, v;

> select * from cm_result_data;
+-----+-------+---------+------+
| eid | am    | last_am | v    |
+-----+-------+---------+------+
| 100 | 24100 |    NULL |   80 |
| 100 | 24097 |   24099 |   80 |
| 100 | 24099 |   24100 |   90 |
| 200 | 24100 |    NULL |   80 |
| 200 | 24097 |   24099 |   80 |
| 200 | 24099 |   24100 |   90 |
+-----+-------+---------+------+
6 rows in set (0.00 sec)

现在这是我们想要的结果集,这就是为什么这个视图被称为 cm_result_data。所缺少的只是将绝对月份转换回 (y,m) 元组。

为此,我们只需加入表month_value。

只有两个问题: 1) 我们希望输出中的月份 before last_am,并且 2)我们的数据中没有下个月的地方有空值;为了满足 OP 的规范,这些应该是单月范围。

编辑:这些实际上可能比一个月更长,但在每种情况下,它们都意味着我们需要找到开斋节的最新月份,即:

(select max(am) from cm_abs_month d where d.eid = a.eid )

因为视图分解了问题,我们可以在一个月前通过添加另一个视图来添加这个“结束上限”,但我只是将它插入到合并中。哪个最有效取决于您的 RDBMS 如何优化查询。

要获得前一个月,我们将加入 (cm_result_data.last_am - 1 = cm_abs_month.am)

只要我们有一个空值,OP 希望“to”月份与“from”月份相同,因此我们将只使用 coalesce:coalesce(last_am,am)。由于 last 消除了任何空值,因此我们的连接不需要是外连接。

> select a.eid, b.m, b.y, c.m, c.y, a.v 
   from cm_result_data a 
    join cm_abs_month b 
      on ( a.eid = b.eid and a.am = b.am)  
    join cm_abs_month c 
      on ( a.eid = c.eid and 
      coalesce( a.last_am - 1, 
              (select max(am) from cm_abs_month d where d.eid = a.eid )
      ) = c.am)
    order by 1, 3, 2, 5, 4;
+-----+------+------+------+------+------+
| eid | m    | y    | m    | y    | v    |
+-----+------+------+------+------+------+
| 100 |    1 | 2008 |    2 | 2008 |   80 |
| 100 |    3 | 2008 |    3 | 2008 |   90 |
| 100 |    4 | 2008 |    4 | 2008 |   80 |
| 200 |    1 | 2008 |    2 | 2008 |   80 |
| 200 |    3 | 2008 |    3 | 2008 |   90 |
| 200 |    4 | 2008 |    4 | 2008 |   80 |
+-----+------+------+------+------+------+

通过加入,我们得到了 OP 想要的输出。

并不是说我们必须重新加入。碰巧的是,absolute_month 函数是双向的,所以我们可以重新计算年份并从中偏移月份。

首先,让我们处理添加“结束上限”月份:

> create or replace view cm_capped_result as 
select eid, am, 
  coalesce( 
   last_am - 1, 
   (select max(b.am) from cm_abs_month b where b.eid = a.eid)
  ) as last_am, v  
 from cm_result_data a;

现在我们得到了按照 OP 格式化的数据:

select eid, 
 ( (am - 1) % 12 ) + 1 as sm, 
 floor( ( am - 1 ) / 12 ) as sy, 
 ( (last_am - 1) % 12 ) + 1 as em, 
 floor( ( last_am - 1 ) / 12 ) as ey, v    
from cm_capped_result 
order by 1, 3, 2, 5, 4;

+-----+------+------+------+------+------+
| eid | sm   | sy   | em   | ey   | v    |
+-----+------+------+------+------+------+
| 100 |    1 | 2008 |    2 | 2008 |   80 |
| 100 |    3 | 2008 |    3 | 2008 |   90 |
| 100 |    4 | 2008 |    4 | 2008 |   80 |
| 200 |    1 | 2008 |    2 | 2008 |   80 |
| 200 |    3 | 2008 |    3 | 2008 |   90 |
| 200 |    4 | 2008 |    4 | 2008 |   80 |
+-----+------+------+------+------+------+

还有 OP 想要的数据。一切都在 SQL 中,应该在任何 RDBMS 上运行,并被分解为简单、易于理解和易于测试的视图。

重新加入好还是重新计算好?我会把这个(这是一个棘手的问题)留给读者。

(如果您的 RDBMS 不允许在视图中进行分组,则您必须先加入,然后再分组,或者分组,然后使用相关的子查询拉入月份和年份。这留给读者练习.)


Jonathan Leffler 在 cmets 中提问,

如果存在,您的查询会发生什么 是数据中的空白(比如说有一个 2007-12 年的条目,值为 80,和 2007-10 年的另一个,但没有一个 2007-11 年?问题不清楚是什么 应该发生在那里。

嗯,你是对的,OP 没有具体说明。也许有一个(未提及的)前提条件是没有差距。在没有要求的情况下,我们不应该尝试围绕可能不存在的东西进行编码。但事实是,差距导致“回归”战略失败;在这些条件下,“重新计算”策略不会失败。我会说更多,但这会揭示我上面提到的技巧问题中的技巧。

【讨论】:

  • 很好的解释——恐怕你不会得到应有的支持。我一直在断断续续地进行类似的解释。我遇到的一个问题是使用“年 * 100 + 月”而不是“年 * 12 + 月”,所以虽然我有易于阅读的年/月组合,...(续)...
  • ...(续)...和可靠的排序,我没有一个简单的连续性标准。如果数据中存在空白,您的查询会发生什么情况(例如,2007-12 有一个条目,值为 80,2007-10 有另一个条目,但 2007-11 没有一个条目?问题不清楚那里应该发生什么.
  • 感谢您的回答,这是将 SQL 分解为无法测试的更小的查询的一个很好的示例。这个具体的例子也解决了一个普遍的问题:将时间序列中给出的信息减少到一个时间数据库中,其中每个信息都有有效时间。例如,这适用于“合同”数据库,然后可用于有效地计算价值变化“事件”。
【解决方案2】:

我让它按如下方式工作。它的分析功能很重,并且是 Oracle 特有的。

select distinct id, value,
decode(startMonth, null,
  lag(startMonth) over(partition by id, value order by startMonth, endMonth),  --if start is null, it's an end so take from the row before
startMonth) startMonth,

  decode(endMonth, null,
  lead(endMonth) over(partition by id, value order by startMonth, endMonth),  --if end is null, it's an start so take from the row after
endMonth) endMonth    

from (
select id, value, startMonth, endMonth from(
select id, value, 
decode(month+1, lead(month) over(partition by id,value order by month), null, month)     
startMonth, --get the beginning month for each interval
decode(month-1, lag(month) over(partition by id,value order by month), null, month)     
endMonth --get the end month for each interval from Tbl
) a 
where startMonth is not null or endMonth is not null --remain with start and ends only
)b

或许可以稍微简化一些内部查询

内部查询检查月份是否是间隔的第一个月/最后一个月,如下所示:如果月份 + 1 == 该分组的下个月(滞后),那么由于有下个月,这个月是显然不是月底。否则,它间隔的最后一个月。相同的概念用于检查第一个月。

外部查询首先过滤掉所有不是开始月或结束月的行 (where startMonth is not null or endMonth is not null)。 然后,每一行是开始月份或结束月份(或两者),由 start 或 end 是否不为空来确定)。如果月份是开始月份,则通过获取该 id 的下一个(领先)endMonth 来获取相应的结束月份,值按 endMonth 排序,如果是 endMonth,则通过查找上一个 startMonth(滞后)来获取 startMonth

【讨论】:

  • ngz,你有没有机会为这个解决方案提供解释计划?
  • 添加到解决方案的说明
【解决方案3】:

这个只使用一次表扫描并且可以跨年工作。最好将您的月份和年份列建模为只有一个日期数据类型列:

SQL> create table tbl (id,month,year,value)
  2  as
  3  select 100,12,2007,80 from dual union all
  4  select 100,1,2008,80 from dual union all
  5  select 100,2,2008,80 from dual union all
  6  select 100,3,2008,90 from dual union all
  7  select 100,4,2008,80 from dual union all
  8  select 200,12,2007,50 from dual union all
  9  select 200,1,2008,50 from dual union all
 10  select 200,2,2008,40 from dual union all
 11  select 200,3,2008,50 from dual union all
 12  select 200,4,2008,50 from dual union all
 13  select 200,5,2008,50 from dual
 14  /

Tabel is aangemaakt.

SQL> select id
  2       , mod(min(year*12+month-1),12)+1 startmonth
  3       , trunc(min(year*12+month-1)/12) startyear
  4       , mod(max(year*12+month-1),12)+1 endmonth
  5       , trunc(max(year*12+month-1)/12) endyear
  6       , value
  7    from ( select id
  8                , month
  9                , year
 10                , value
 11                , max(rn) over (partition by id order by year,month) maxrn
 12             from ( select id
 13                         , month
 14                         , year
 15                         , value
 16                         , case lag(value) over (partition by id order by year,month)
 17                           when value then null
 18                           else rownum
 19                           end rn
 20                      from tbl
 21                  ) inner
 22         )
 23   group by id
 24       , maxrn
 25       , value
 26   order by id
 27       , startyear
 28       , startmonth
 29  /

        ID STARTMONTH  STARTYEAR   ENDMONTH    ENDYEAR      VALUE
---------- ---------- ---------- ---------- ---------- ----------
       100         12       2007          2       2008         80
       100          3       2008          3       2008         90
       100          4       2008          4       2008         80
       200         12       2007          1       2008         50
       200          2       2008          2       2008         40
       200          3       2008          5       2008         50

6 rijen zijn geselecteerd.

问候, 抢。

【讨论】:

    【解决方案4】:

    当输入表包含跨越数年的多个 ID 和日期范围时,我无法从 ngz 获得响应。我有一个可行的解决方案,但有资格。如果您知道该范围内的每个月/年/id 组合都有一行,它只会给您正确的答案。如果有“洞”,它就行不通。如果您有漏洞,我知道除了编写一些 PL/SQL 并使用游标循环以您想要的格式创建新表之外,还有其他好方法。

    顺便说一句,这就是为什么以这种方式建模的数据令人讨厌的原因。您应该始终将内容存储为开始/从范围记录,而不是离散时间段记录。用“乘数”表将前者转换为后者是微不足道的,但几乎不可能(如您所见)朝另一个方向发展。

    SELECT ID
         , VALUE
         , start_date
         , end_date
      FROM (SELECT ID
                 , VALUE
                 , start_date
                 , CASE
                      WHEN is_last = 0
                         THEN LEAD(end_date) OVER(PARTITION BY ID ORDER BY start_date)
                      ELSE end_date
                   END end_date
                 , is_first
              FROM (SELECT ID
                         , VALUE
                         , TO_CHAR(the_date, 'YYYY.MM') start_date
                         , TO_CHAR(NVL(LEAD(the_date - 31) OVER(PARTITION BY ID ORDER BY YEAR
                                      , MONTH), the_date), 'YYYY.MM') end_date
                         , is_first
                         , is_last
                      FROM (SELECT ID
                                 , YEAR
                                 , MONTH
                                 , TO_DATE(TO_CHAR(YEAR) || '.' || TO_CHAR(MONTH) || '.' || '15', 'YYYY.MM.DD') the_date
                                 , VALUE
                                 , ABS(SIGN(VALUE -(NVL(LAG(VALUE) OVER(PARTITION BY ID ORDER BY YEAR
                                                       , MONTH), VALUE - 1)))) is_first
                                 , ABS(SIGN(VALUE -(NVL(LEAD(VALUE) OVER(PARTITION BY ID ORDER BY YEAR
                                                       , MONTH), VALUE - 1)))) is_last
                              FROM test_table)
                     WHERE is_first = 1
                        OR is_last = 1))
     WHERE is_first = 1
    

    【讨论】:

    • 请参阅我上面的解决方案,了解即使有“漏洞”也能正常工作的版本(第二版)。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2014-01-31
    • 1970-01-01
    • 2016-12-16
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多