SQL经典实例——涉及区间的查询

涉及区间的查询

1、找出一系列连续的值

问题:你想判断哪些行表示一系列连续的项目。请看下面从视图V 返回的结果集,它包含一系列项目及其开始日期和结束日期。

sql 复制代码
select *
  from V

PROJ_ID PROJ_START  PROJ_END
------- ----------- -----------
      1 01-JAN-2020 02-JAN-2020
      2 02-JAN-2020 03-JAN-2020
      3 03-JAN-2020 04-JAN-2020
      4 04-JAN-2020 05-JAN-2020
      5 06-JAN-2020 07-JAN-2020
      6 16-JAN-2020 17-JAN-2020
      7 17-JAN-2020 18-JAN-2020
      8 18-JAN-2020 19-JAN-2020
      9 19-JAN-2020 20-JAN-2020
     10 21-JAN-2020 22-JAN-2020
     11 26-JAN-2020 27-JAN-2020
     12 27-JAN-2020 28-JAN-2020
     13 28-JAN-2020 29-JAN-2020
     14 29-JAN-2020 30-JAN-2020

所谓表示一系列连续项目的行,指的是除第一行外,其他各行的 PROJ_START 都与其前一行的 PROJ_END 相等(这里的前一行,指的是 PROJ_ID 比当前行小 1 的行)​。在视图 V 返回的前 5 行中,PROJ_ID 为 1~3 的行属于同一"组"​,因为其中每行的 PROJ_END 都与下一行的 PROJ_START 相等。由于你要找出一系列连续项目所在的日期范围,因此要返回所有其 PROJ_END 与下一行的 PROJ_START 相等的行。如果整个结果集只包含前5 行,那么你要返回的是前 3 行。最终的结果集如下所示(以视图 V 中的全部 14 行为例)​。

sql 复制代码
PROJ_ID PROJ_START  PROJ_END
------- ----------- -----------
     1  01-JAN-2020 02-JAN-2020
     2  02-JAN-2020 03-JAN-2020
     3  03-JAN-2020 04-JAN-2020
     6  16-JAN-2020 17-JAN-2020
     7  17-JAN-2020 18-JAN-2020
     8  18-JAN-2020 19-JAN-2020
    11  26-JAN-2020 27-JAN-2020
    12  27-JAN-2020 28-JAN-2020
    13  28-JAN-2020 29-JAN-2020

上述结果集中没有包含 PROJ_ID 为 4、5、9、10 和 14的行,因为这些行的 PROJ_END 与下一行的PROJ_START 都不相等。

解决方案:本解决方案利用窗口函数 LEAD OVER 来查找下一行的BEGIN_DATE,从而避免使用自连接。而在窗口函数被引入前,必须使用自连接。

sql 复制代码
select proj_id, proj_start, proj_end
  from (
select proj_id, proj_start, proj_end,
       lead(proj_start)over(order by proj_id) next_proj_start
  from V
       ) alias
where next_proj_start = proj_end;

2、找出同一个分组或分区中相邻行的差

问题:你想返回每位员工的 DEPTNO、ENAME、SAL 及其与当前部门中(DEPTNO 相同)下一位员工的 SAL 差(以判断在同一部门中,资历和薪水之间是否存在相关性)​。这里的下一位员工是根据获聘时间确定的。对于每个部门最后获聘的员工,将 SAL 差设置为 N/A。结果集如下所示。

sql 复制代码
DEPTNO ENAME             SAL HIREDATE    DIFF
------ ---------- ---------- ----------- ----------
    10 CLARK            2450 09-JUN-2006      -2550
    10 KING             5000 17-NOV-2006       3700
    10 MILLER           1300 23-JAN-2007        N/A
    20 SMITH             800 17-DEC-2005      -2175
    20 JONES            2975 02-APR-2006        -25
    20 FORD             3000 03-DEC-2006          0
    20 SCOTT            3000 09-DEC-2007       1900
    20 ADAMS            1100 12-JAN-2008        N/A
    30 ALLEN            1600 20-FEB-2006        350
    30 WARD             1250 22-FEB-2006      -1600
    30 BLAKE            2850 01-MAY-2006       1350
    30 TURNER           1500 08-SEP-2006        250
    30 MARTIN           1250 28-SEP-2006        300
    30 JAMES             950 03-DEC-2006        N/A

解决方案:在本实例中,窗口函数 LEAD OVER 和 LAG OVER 又一次提供了极大的方便。无须使用连接,就可以轻松地访问下一行数据和前一行数据。对于这种问题,也可以使用其他方法(比如子查询或自连接)来解决,但显得比较笨拙。

sql 复制代码
 with next_sal_tab (deptno,ename,sal,hiredate,next_sal)
 as
 (select deptno, ename, sal, hiredate,
       lead(sal)over(partition by deptno
                         order by hiredate) as next_sal
   from emp )
 
     select deptno, ename, sal, hiredate
  ,    coalesce(cast(sal-next_sal as char), 'N/A') as diff
     from next_sal_tab;

这里为展示解决方案的多样性,使用的是 CTE 而不是子查询。当前,在大多数 RDBMS 中,这两种方法都管用,具体选择哪种方法,通常取决于可读性。

3、找出连续值构成的区间的起点和终点

问题:本实例是 10.1 节的扩展,也使用 10.1 节中的视图 V。在10.1 节中,你找出了连续值构成的区间,而这里只想找出区间的起点和终点。与 10.1 节不同,在这个实例中,如果某行并非一组连续值的一部分,则你依然要返回它。为什么呢?这是因为这样的行是其自身构成的区间的起点和终点。这里将下面的视图 V 作为数据源。

sql 复制代码
select *
  from V
 
 
PROJ_ID PROJ_START PROJ_END
------- ----------- -----------
      1 01-JAN-2020 02-JAN-2020
      2 02-JAN-2020 03-JAN-2020
      3 03-JAN-2020 04-JAN-2020
      4 04-JAN-2020 05-JAN-2020
      5 06-JAN-2020 07-JAN-2020
      6 16-JAN-2020 17-JAN-2020
      7 17-JAN-2020 18-JAN-2020
      8 18-JAN-2020 19-JAN-2020
      9 19-JAN-2020 20-JAN-2020
     10 21-JAN-2020 22-JAN-2020
     11 26-JAN-2020 27-JAN-2020
     12 27-JAN-2020 28-JAN-2020
     13 28-JAN-2020 29-JAN-2020
     14 29-JAN-2020 30-JAN-2020

而你希望最终的结果集如下所示。

sql 复制代码
PROJ_GRP PROJ_START  PROJ_END
-------- ----------- -----------
       1 01-JAN-2020 05-JAN-2020
       2 06-JAN-2020 07-JAN-2020
       3 16-JAN-2020 20-JAN-2020
       4 21-JAN-2020 22-JAN-2020
       5 26-JAN-2020 30-JAN-2020

解决方案:这个问题比 1 节的问题要复杂些。首先,必须找出所有的区间。一系列行是否构成区间取决于它们的PROJ_START 值和 PROJ_END 值。对于特定的行,仅当其 PROJ_START 值与前一行的 PROJ_END 值相等时,才被视为"连续"的,即属于分组的一部分。如果一行的PROJ_START 值与前一行的 PROJ_END 值不相等,且其PROJ_END 值与后一行的 PROJ_START 值不相等,则它自己构成一个分组。找出所有的区间后,必须将各个区间中的行编组,进而只返回区间的起点和终点。

请看前面要返回的最终结果集的第 1 行,其PROJ_START 为视图 V 中 PROJ_ID 1 的PROJ_START,而 PROJ_END 为视图 V 中 PROJ_ID 4的 PROJ_END。虽然 PROJ_ID 4 后面的值不是连续的,但它是连续值区间的终点,因此包含在第 1 个分组中。

对于这个问题,最简单的解决方法是使用窗口函数 LAGOVER。使用 LAG OVER 来判断当前行的 PROJ_START是否与前一行的 PROJ_END 相等,可以将所有的行分成多组。将行分组后,可以使用聚合函数 MIN 和 MAX 找出各个分组的起点和终点。

sql 复制代码
 select proj_grp, min(proj_start), max(proj_end)
   from (
 select proj_id,proj_start,proj_end,
        sum(flag)over(order by proj_id) proj_grp
   from (
 select proj_id,proj_start,proj_end,
        case when
             lag(proj_end)over(order by proj_id) = proj_start
             then 0 else 1
        end flag
   from V
        ) alias1
        ) alias2
  group by proj_grp;

4、填补值区间空隙

问题:你想返回从 2005 年起的 10 年间,每一年聘请的员工数量,但其中有些年份并没有聘请任何员工。你希望返回如下结果集。

sql 复制代码
YR          CNT
---- ----------
2005          1
2006         10
2007          2
2008          1
2009          0
2010          0
2011          0
2012          0
2013          0
2014          0

解决方案:本解决方案的诀窍是,对于没有聘请任何员工的年份,返回 0。如果某年没有聘请任何员工,那么在 EMP 表中,将没有获聘日期属于该年份的行。既然表中没有获聘日期属于该年份的行,那么怎么返回该年份聘请的员工数量呢?为了解决这个问题,必须使用外连接。你必须提供一个包含所有年份的结果集,然后对 EMP 表执行计数操作,看看各个年份是否聘请了员工。

DB2:将 EMP 表用作透视表(因为它包含 14 行数据)​,并使用内置函数 YEAR 生成 10 行数据,从 2005 年开始的 10年间的每一年对应一行。外连接到 EMP 表,并计算每年聘请的员工数量。

sql 复制代码
 select x.yr, coalesce(y.cnt,0) cnt
   from (
 select year(min(hiredate)over()) -
        mod(year(min(hiredate)over()),10) +
        row_number()over()-1 yr
   from emp fetch first 10 rows only
        ) x
   left join
        (
 select year(hiredate) yr1, count(*) cnt
   from emp
  group by year(hiredate)
        ) y
     on ( x.yr = y.yr1 );

Oracle:Oracle 解决方案的结构与 DB2 解决方案相同,唯一不同的是语法。

sql 复制代码
 select x.yr, coalesce(cnt,0) cnt
   from (
 select extract(year from min(hiredate)over()) -
        mod(extract(year from min(hiredate)over()),10) +
        rownum-1 yr
   from emp
  where rownum <= 10
        ) x
   left join
        (
 select to_number(to_char(hiredate,'YYYY')) yr, count(*) cnt
   from emp
  group by to_number(to_char(hiredate,'YYYY'))
        ) y
     on ( x.yr = y.yr );

PostgreSQL 和 MySQL:将 T10 表作为透视表(因为它包含 10 行数据)​,并使用内置函数 EXTRACT 生成 10 行数据,从 2005 年开始的10 年间的每一年对应一行。外连接到 EMP 表,并计算每年聘请的员工数量。

sql 复制代码
 select y.yr, coalesce(x.cnt,0) as cnt
   from (
 selectmin_year-mod(cast(min_year as int),10)+rn as yr
   from (
 select (select min(extract(year from hiredate))
           from emp) as min_year,
        id-1 as rn
   from t10
        ) a
        ) y
   left join
        (
 select extract(year from hiredate) as yr, count(*) as cnt
   from emp
  group by extract(year from hiredate)
        ) x
     on ( y.yr = x.yr );

SQL Server:将 EMP 表用作透视表(因为它包含 14 行数据)​,并使用内置函数 YEAR 生成 10 行数据,从 2005 年开始的 10年间的每一年对应一行。外连接到 EMP 表,并计算每年聘请的员工数量。

sql 复制代码
 select x.yr, coalesce(y.cnt,0) cnt
   from (
 select top (10)
        (year(min(hiredate)over()) -
         year(min(hiredate)over())%10)+
         row_number()over(order by hiredate)-1 yr
   from emp
        ) x
   left join
        (
 select year(hiredate) yr, count(*) cnt
   from emp
  group by year(hiredate)
        ) y
     on ( x.yr = y.yr );

5、生成连续的数字值

问题:你希望有一个"行源生成器"​(row source generator)​,以便在查询中使用它。对于需要透视的查询,行源生成器很有用。例如,你想返回如下结果集,它包含指定的行数。

sql 复制代码
ID
---
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
...

如果 RDBMS 提供了动态返回行的内置函数,就不用预先创建包含固定行数的透视表,这就是动态行生成器可以提供极大方便的原因。如果 RDBMS 没有提供这样的函数,你就必须在需要时使用包含固定行数的传统透视表来生成行(这可能还不够)​。

解决方案:本解决方案演示如何返回 10 行数据,其中包含的数字从1 开始不断递增。你可以轻松地修改本解决方案,以返回任意的行数。

能够返回不断递增的值,这种能力打开了通往众多其他解决方案的大门。例如,你可以生成一系列数字,再将它们与特定日期相加,以生成日期序列。你还可以使用这些数字来分析字符串。

DB2 和 SQL Server:使用递归式 WITH 子句来生成一系列行,其中包含不断递增的值。实际上,在大多数 RDBMS 中,可以使用递归CTE 来解决这个问题。

sql 复制代码
with x (id)
as (
select 1
 union all
select id+1
  from x
 where id+1 <= 10
)
select * from x;

Oracle:在 Oracle Database 中,可以使用 MODEL 子句来生成行。

sql 复制代码
select array id
  from dual
 model
   dimension by (0 idx)
   measures(1 array)
   rules iterate (10) (
     array[iteration_number] = iteration_number+1
   );

PostgreSQL:使用为生成行而专门设计的函数 GENERATE_SERIES。

sql 复制代码
select id
from generate_series (1, 10) x(id);

 id 
----
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
(10 rows)

MySQL

sql 复制代码
SELECT ROW_NUMBER() OVER () AS num
FROM information_schema.columns
LIMIT 10;


WITH RECURSIVE seq AS (
    SELECT 1 AS num
    UNION ALL
    SELECT num + 1 FROM seq WHERE num < 10
)
SELECT num FROM seq;