SQL使用技巧

前言

本博客用于记录本人在学习SQL知识过程中遇见的各类值得记录的学习技巧。

1. CASE表达式

1.1 聚合结果行列转换

问题:
假设有如下表格sample1_1:

itemprice
电脑2000
手机1000
电视3000
手机1000
电视3000
电脑2000
手机1000
电视3000
电脑2000
电视3000
手机1000
电视3000

现要求得到如下结果:

num_computernum_phonenum_TV
345

解决方法:
乍一看这个结果是对表格中的项目进行了分组并统计了数量,初始觉得应该使用 GROUP BY 子句,但是进一步观察所要求的结果与 GROUP BY 子句汇总的结果并不一样,相当于对GROUP BY 汇总结果进行了转置,结果有几列就应该在SELECT子句中选取几列。这一问题是用CASE表达式进行解决的:

SELECT SUM(CASE WHEN item = "电脑" THEN 1 ELSE 0 END) AS num_computer, 
    SUM(CASE WHEN item = "手机" THEN 1 ELSE 0 END) AS num_phone,
    SUM(CASE WHEN item = "电视" THEN 1 ELSE 0 END) AS num_TV
FROM sample1_1

1.2 已有数据重分组

问题:
假设有如下表格sample1_2:

pref_name(县名)population(人口)
德岛100
香川200
爱媛150
高知200
福冈300
佐贺100
长崎200
东京400
群马50

现要求以东北、关东、九州等地区为单位来分组,并统计人口数量,以得到如下结果:

地区名人口
四国650
九州600
其他450

其中,“四国”对应的是表 sample1_2 中的“德岛、香川、爱媛、高知”,“九州”对应的是表 sample1_2 中的“福冈、佐贺、长崎”。

解决方法:
这个问题的常见思路是,将 sample1_2 与另一包含地区信息的表进行联结,然后再根据地区列进行分组统计即可。而如果使用 CASE 表达式,则用如下所示的一条 SQL 语句就可以完成:

SELECT CASE pref_name
			WHEN  '德岛' THEN  '四国'
			WHEN  '香川' THEN  '四国'
			WHEN  '爱媛' THEN  '四国'
			WHEN  '高知' THEN  '四国'
			WHEN  '福冈' THEN  '九州'
			WHEN  '佐贺' THEN  '九州'
			WHEN  '长崎' THEN  '九州'
		ELSE  '其他' END AS district,
		SUM(population)
FROM sample1_2 
GROUP BY CASE pref_name
			WHEN  '德岛' THEN  '四国'
			WHEN  '香川' THEN  '四国'
			WHEN  '爱媛' THEN  '四国'
			WHEN  '高知' THEN  '四国'
			WHEN  '福冈' THEN  '九州'
			WHEN  '佐贺' THEN  '九州'
			WHEN  '长崎' THEN  '九州'
		  ELSE  '其他' END;

这里的关键在于将 SELECT 子句里的 CASE 表达式复制到 GROUP BY 子句里。不过,必须在 SELECT 子句和 GROUP BY 子句这两处写一样的 CASE 表达式,这有点儿麻烦。后期需要修改的时候,很容易发生只改了这一处而忘掉改另一处的失误。

MySQL 支持如下改写:

SELECT CASE pref_name
			WHEN  '德岛' THEN  '四国'
			WHEN  '香川' THEN  '四国'
			WHEN  '爱媛' THEN  '四国'
			WHEN  '高知' THEN  '四国'
			WHEN  '福冈' THEN  '九州'
			WHEN  '佐贺' THEN  '九州'
			WHEN  '长崎' THEN  '九州'
		ELSE  '其他' END AS district,
		SUM(population)
FROM sample1_2 
GROUP BY district;

上述 GROUP BY 子句使用的是 SELECT 子句里定义的列别称 district。严格来说,这种写法违反了标准 SQL 的规则,因为 GROUP BY 子句比 SELECT 语句先执行,所以在 GROUP BY 子句中引用在 SELECT 子句里定义的别称是不被允许的。不过 MySQL 支持这种 SQL 语句,这个查询语句可以顺利执行。这是因为,MySQL 在执行查询语句时,会先对 SELECT 子句里的列表进行扫描,并对列进行计算。

1.3 进行不同条件的统计

本例与1.1较为相似,相当于将原始表变成了交叉表。

问题:
我们在 sample1_2 的基础上添加了“性别”列,得到了如下表格sample1_3:

pref_name(县名)sex(性别)population(人口)
德岛160
德岛240
香川1100
香川2100
爱媛1100
爱媛250
高知1100
高知2100
福冈1100
福冈2200
佐贺120
佐贺280
长崎1125
长崎2125
东京1250
东京2150

现在要求按照性别、县名汇总的人数,得到如下表所示的结果:

县名
德岛6040
香川100100
爱媛10050
高知100100
福冈100200
佐贺2080
长崎125125
东京250150

解决方法:

SELECT pref_name,
	-- 男性人口
	SUM( CASE WHEN sex = '1' THEN population ELSE 0 END) AS cnt_m,
	-- 女性人口
	SUM( CASE WHEN sex = '2' THEN population ELSE 0 END) AS cnt_f
FROM sample1_3
GROUP BY pref_name;

1.4 CHECK 约束

在 CHECK 约束里对 CASE 表达式使用的较多,CHECK 约束是指约束表中某一个或者某些列中可接受的数据值或者数据格式(用于限制列中的值的范围)。

例如,某公司规定“女性员工的工资必须在 20 万日元以下”,这条规定使用 CHECK 约束来描述,对应的代码如下:

-- 蕴含式(conditional)逻辑表达式
-- 如果是女性员工,则工资是 20 万日元以下(如果不是女性员工,则无需考虑该约束)
CONSTRAINT check_salary CHECK
	( CASE WHEN sex = '2'
		   THEN CASE WHEN salary <= 200000
					 THEN 1 ELSE 0 END
		   ELSE 1 END = 1 )

然而,MySQL 所有的存储引擎均不支持 CHECK 约束,MySQL会对 CHECK 子句进行分析,但是在插入数据时会忽略,因此 CHECK 并不起作用,因此实现对数据约束有两种方法:

  • 在 MySQL 中约束,如使用 ENUM 类型(接受最多64 K个串组成的一个预定义集合(ENUM(<值1>, <值2>, …))的某个串)或者触发器等。
  • 在应用程序里面对数据进行检查再插入。

1.5 UPDATE 语句中的条件分支

问题1:
假设有如下表格sample1_5_1:

namesalary
相田300,000
神崎270,000
木村220,000
齐藤290,000

现在需要根据以下条件对上表的数据进行更新:

  • 对当前工资为 30 万日元以上的员工,降薪 10%。
  • 对当前工资为 25 万日元以上且不满 28 万日元的员工,加薪 20%。

得到如下表所示的结果:

namesalary
相田270,000
神崎324,000
木村220,000
齐藤290,000

解决方法:
如果按照 UPDATE 语句逐条更新数据会出现问题:工资 30 万日元的员工先降为 27 万,再升为 32.4 万日元。即使更改了更新次序,也会使得其他工资的员工在更新时发生问题。

可以在 UPDATE 语句中使用 CASE 表达式即可解决这一问题:

UPDATE sample1_5_1
   SET salary = CASE WHEN salary >= 300000
					 THEN salary * 0.9
					 WHEN salary >= 250000 AND salary < 280000
					 THEN salary * 1.2
					 ELSE salary END;

问题2:
假设有如下表格sample1_5_2:

p_keycol_1(第 1 列)col_2(第 2 列)
a1
b2
c3

现要求完成列 p_key 中 a 和 b 的值调换,得到如下结果:

p_keycol_1(第 1 列)col_2(第 2 列)
b1
a2
c3

解决方法:

UPDATE sample1_5_2
SET p_key = CASE WHEN p_key= 'a'
			 	 THEN 'b'
				 WHEN p_key = 'b'
				 THEN  'a'
				 ELSE p_key END
WHERE p_key IN ('a', 'b');

注意,对于 MySQL 来说,p_key 列不能设置主键约束或者唯一键约束,否则会因键值重复而出现错误。

1.6 表之间的数据匹配

问题:
假设有如下表格sample1_6_1:

course_ idcourse_ name
1会计入门
2财务知识
3簿记考试
4税务师

和表格sample1_6_2:

monthcourse id
2007061
2007063
2007064
2007074
2007082
2007084

现要求根据这两张表生成如下结果的交叉表,以方便了解每个月开设的课程:

course_name6 月7 月8 月
会计入门××
财务知识××
簿记考试××
税务师

解决方法:

-- 使用 IN 谓词
SELECT course_name, 
       (CASE WHEN course_id in (SELECT course_id 
								FROM sample1_6_2 
								WHERE month='200706') 
			 THEN '○' ELSE '×' END) AS '6月',
       (CASE WHEN course_id in (SELECT course_id 
								FROM sample1_6_2 
								WHERE month='200707') 
			 THEN '○' ELSE '×' END) AS '7月',
       (CASE WHEN course_id in (SELECT course_id 
								FROM sample1_6_2 
								WHERE month='200708') 
			 THEN '○' ELSE '×' END) AS '8月',
FROM sample1_6_1;

-- 使用 EXISTS 谓词
SELECT t1.course_name, 
       (CASE WHEN EXISTS (SELECT course_id 
						  FROM sample1_6_2 t2
					 	  WHERE month='200706'
						  AND t2.course_id = t1.course_id) 
			 THEN '○' ELSE '×' END) AS '6月',
       (CASE WHEN EXISTS (SELECT course_id 
						  FROM sample1_6_2 t2
					 	  WHERE month='200707'
						  AND t2.course_id = t1.course_id) 
			 THEN '○' ELSE '×' END) AS '7月',
       (CASE WHEN EXISTS (SELECT course_id 
						  FROM sample1_6_2 t2
					 	  WHERE month='200708'
						  AND t2.course_id = t1.course_id) 
			 THEN '○' ELSE '×' END) AS '8月',
FROM sample1_6_1 t1;

使用 IN 和 EXISTS 谓词得到的结果是一样的,但从性能方面来说 EXISTS 更好。通过 EXISTS 进行的子查询能够用到“month, course_id”这样的主键索引,尤其是当表 sample1_6_2 里数据比较多的时候更有优势。

1.7 在 CASE 表达式中使用聚合函数

问题:
假设有如下显示学生及其所加入社团的表格sample1_7:

std_id( 学号 )club_id( 社团 ID)club_name(社团名)main_club_flg(主社团标志)
1001棒球Y
1002管弦乐N
2002管弦乐N
2003羽毛球Y
2004足球N
3004足球N
4005游泳N
5006围棋N

这张表的主键是“学号、社团 ID”,存储了学生和社团之间多对多的关系。有的学生同时加入了多个社团,有的学生只加入了某一个社团。对于加入了多个社团的学生,我们通过将其“主社团标志”列设置为 Y 或者 N 来表明哪一个社团是他的主社团;对于只加入了一个社团的学生,我们将其“主社团标志”列设置为 N。

现在要求获取只加入了一个社团的学生及社团 ID 或者加入多个社团的学生及主社团 ID,得到如下所示的结果:

std_idmain_club
1001
2003
3004
4005
5006

解决方法:
很容易想到的办法是,针对两种情况分别编写 SQL 语句然后再进行 UNION 联合:

-- 条件 1 :选择只加入了一个社团的学生
SELECT std_id, MAX(club_id) AS main_club
FROM sample1_7
GROUP BY std_id
HAVING COUNT(*) = 1;
UNION
-- 条件 2 :选择加入了多个社团的学生
SELECT std_id, club_id AS main_club
FROM sample1_7
WHERE main_club_flg =  'Y' ;

这样做确实能得到正确的结果,但需要写多条 SQL 语句。而如果使用 CASE 表达式,写一条 SQL 语句就可以了:

SELECT std_id,
	   CASE WHEN COUNT(*) = 1 -- 只加入了一个社团的学生
	   		THEN MAX(club_id)
			ELSE MAX(CASE WHEN main_club_flg =  'Y'
						  THEN club_id
						  ELSE NULL END)
		END AS main_club
FROM sample1_7
GROUP BY std_id;

1.8 多列数据的最大值

问题:
从多行数据里选出最大值或最小值很容易——通过 GROUP BY 子句对合适的列进行聚合操作,并使用 MAX 或 MIN 聚合函数就可以求出。那么,从多列数据里选出最大值该怎么做呢?假设有如下表格sample1_8:

keyxyz
A123
B552
C471
D338

现要求选取出每一行中 x、y、z 的最大值,得到如下结果:

keygreatest
A3
B5
C7
D8

解决方法:
MySQL 中存在 GREATEST 函数可以轻松实现该功能:

SELECT key, GREATEST(X, Y, Z) AS greatest
FROM sample1_8

如果不用 GREATEST 函数而是用 CASE 表达式,可以实现如下:

SELECT key,
       CASE WHEN (CASE WHEN x<y THEN y ELSE x END) < z
            THEN z
            ELSE (CASE WHEN x<y THEN y ELSE x END) END 
FROM sample1_8

当增加到 4 列、5 列时,推荐直接使用 GREATEST 函数,当然也可以继续用 CASE 表达式来扩展,但是这样写出来的代码会因为嵌套太深而变得不易阅读。可以考虑像下面这样,先进行行列转换,然后使用 MAX 函数来求解:

SELECT key, MAX(col) AS greatest
FROM (SELECT key, x AS col FROM Greatests
	  UNION ALL
	  SELECT key, y AS col FROM Greatests
	  UNION ALL
	  SELECT key, z AS col FROM Greatests)TMP
GROUP BY key;

2. 集合运算

2.1 实现交集和差集运算

问题:
对于 SQL 中常用的集合运算符 INTERSECT、UNION、EXCEPT,MySQL 仅支持 UNION 运算符,不支持其他两个,那如何在MySQL中实现 INTERSECT 和 EXCEPT 的功能呢?

解决方法:
使用 JOIN 可以解决该问题,假设存在表 table1 和表 table2 两个表:

  • 使用内联结(INNER JOIN)方法将要选取的列作为聚合键,并在 SELECT 子句中使用 DISTINCT 关键字即可实现交集运算:
SELECT DISTINCT <table1>.<col1>, <table1>.<col2>, ...
FROM <table1> INNER JOIN <table2>
ON <table1>.<col1> = <table2>.<col1> AND <table1>.<col2> = <table2>.<col2> AND ...
  • 使用外联结(OUTER JOIN)方法将要选取的列作为聚合键,并在 WHERE 子句中加入 NULL 判断条件即可实现差集运算:
-- 表table1独有
SELECT <table1>.<col1>, <table1>.<col2>, ...
FROM <table1> LEFT OUTER JOIN <table2>
ON <table1>.<col1> = <table2>.<col1> AND <table1>.<col2> = <table2>.<col2> AND ...
-- 聚合建一般不允许出现NULL,只判断一个聚合建是否为NULL即可
WHERE <table2>.<col1> IS NULL;

-- 表table2独有
SELECT <table2>.<col1>, <table2>.<col2>, ...
FROM <table1> RIGHT OUTER JOIN <table2>
ON <table1>.<col1> = <table2>.<col1> AND <table1>.<col2> = <table2>.<col2> AND ...
WHERE <table1>.<col1> IS NULL;

2.2 带余除法 (division with a remainder)

问题:
集合运算中的加减乘运算在SQL中均有一定的实现方法(UNION、EXCEPT、INTERSECT、CROSS JOIN),虽然MySQL不支持 EXCEPT 和 INTERSECT 运算符,但是在本博客2.1节我们还是通过一定的方法实现了这两种运算。对于集合运算中的除法还没有介绍。集合运算中的除法通常称为关系除法。

假设有表 Phone 和表 PersonPhone如下所示:

Phone
Apple
vivo
oppo
personphone
JohnApple
Johnoppo
Johnxiaomi
Johnvivo
Maryxiaomi
Maryvivo
Maryoppo
LucyApple
Lucyoppo
Lucyvivo
Deanvivo
Jackhuawei

PersonPhone 表中保存了某几个人所拥有的手机品牌。问题是,如何从该表中选取出拥有 Phone 表中所有3个手机品牌的人员。

解决方法:
如下代码可以解决这一问题。使用关联子查询,在遍历每条记录时,都会在where子句中先找出对应记录的人员所拥有的所有手机品牌,再利用差集运算计算 Phone 表排除当前人所拥有的手机品牌后,是否存在剩余,如果不存在,说明当前人员拥有 Phone 表中全部手机品牌。

SELECT DISTINCT person
FROM PersonPhone PP1
WHERE NOT EXISTS
	   (SELECT phone
		FROM Phone
		EXCEPT
		SELECT phone
		FROM PersonPhone PP2
		WHERE PP1.person = PP2.person);

由于MySQL不能使用EXCEPT运算符,因此需要重新考虑实现方法。我们首先利用内联结将表 Phone 和表 PersonPhone 联结起来,并将联结结果按照人员分组,并计算每人所拥有的手机数量。如果该人员拥有 Phone 表中的全部手机品牌,那么联结结果表中人员手机数量应该等于3。

select  t1.person
FROM 
	(SELECT PP2.person, count(*) as cnt
	FROM Phone INNER JOIN PersonPhone PP
	ON Phone.phone = PP.phone
	group by PP.person
	) t1
where cnt = (select count(*)
		     from Phone);

-- 也可以这样写,更简便
SELECT PP.person
FROM Phone INNER JOIN PersonPhone PP
ON Phone.phone = PP.phone
group by PP.person
having count(*) = (select count(*) from Phone);

3. 自连接

SQL 的连接运算根据其特征的不同,有着不同的名称,如内连接、外连接、交叉连接等。一般来说,这些连接大都是以不同的表或视图为对象进行的,但针对相同的表或相同的视图的连接也并没有被禁止。针对相同的表进行的连接被称为“自连接”(self join)。

3.1 非等值自连接

问题:
假设有如下表格sample3_1:

name(商品名称)price(价格)
苹果50
橘子100
香蕉80

现要求得到上表中商品的两两组合(无序对),结果如下:

name_1name_2
苹果橘子
香蕉橘子
香蕉苹果

解决方法:
在自连接中运用非等值连接即可解决这一问题,按字符顺序排列各商品,只与“字符顺序比自己靠前”的商品进行配对:

SELECT t1.name AS name_1, t2.name AS name_2
FROM sample3_1 t1 CROSS JOIN sample3_1 t2
WHERE t1 .name > t2.name;

想要获取 3 个以上元素的组合时,像下面这样进行扩展即可:

SELECT t1.name AS name_1, t2.name AS name_2, t3.name AS name_3
FROM sample3_1 t1 CROSS JOIN sample3_1 t2 CROSS JOIN sample3_1 t3
WHERE t1.name > t2.name AND t2.name > t3.name;

使用等号“=”以外的比较运算符,如“<、>、<>”进行的连接称为“非等值连接”。这里将非等值连接与自连接结合使用了,因此称为“非等值自连接”。

3.2 删除重复行

问题:
假设有如下表格sample3_2:

idname(商品名称)price(价格)
1苹果50
2橘子100
3橘子100
4橘子100
5香蕉80

先要求删除其中的重复行,得到如下的结果:

idname(商品名称)price(价格)
1苹果50
4橘子100
5香蕉80

解决方法:
如果要求得到的结果中不包含主键 id,那么这个问题利用 DISTINCT 关键字即可解决:

SELECT DISTINCT name, price
FROM sample3_2

对于包含主键 id 的表,我们可以利用关联子查询或非等值连接直接删除重复行:

-- 关联子查询
DELETE FROM sample3_2 t1
WHERE id < ( SELECT MAX(t2.id)
			 FROM sample3_2 t2
			 WHERE t1.name = t2.name AND t1.price = t2.price ) ;
-- 非等值连接
DELETE FROM sample3_2 t1
WHERE EXISTS ( SELECT *
			   FROM sample3_2 t2
			   WHERE t1.name = t2.name AND t1.price = t2.price
			   AND t1.id < t2.id );

3.3 查找局部不一致的列

问题:
假设有如下表格sample3_3,主键是人名,同一家人的家庭 ID 相同:

name(姓名)family_id(家庭 ID)address(住址)
前田义明100东京都港区虎之门 3-2-29
前田由美100东京都港区虎之门 3-2-92
前田静香100东京都港区虎之门 3-2-29
加藤茶200东京都新宿区西新宿 2-8-1
加藤胜200东京都新宿区西新宿 2-8-1
福尔摩斯300贝克街 221B
华生400贝克街 221B

通常来说,同一家人应该住在同一个地方(如加藤家),但也有像福尔摩斯和华生这样不是一家人却住在一起的情况。前田家的家庭 ID 一致,但是家庭住址却因为写错而导致二人不一致。

现要求找出同一家人(同一家庭 ID)但住址却不同的记录。

解决方法:
使用非等值自连接来解决该问题,代码会非常简洁:

SELECT DISTINCT t1.name, t1.address
FROM sample3_3 t1 CROSS JOIN sample3_3 t2
WHERE t1.family_id = t2.family_id AND t1.address <> t2.address;

该问题也可以使用关联子查询进行解决,此时不再需要使用 DISTINCT 关键字:

SELECT t1.name, t1.price
FROM sample3_3 t1
WHERE EXISTS (SELECT *
			  FROM sample3_3 t2
			  WHERE t1.family_id = t2.family_id
			  AND t1.address <> t2.address
			);

3.4 排序

问题1:
假设有如下表格sample3_4_1:

name(商品名称)price(价格)
苹果50
橘子100
葡萄50
西瓜80
柠檬30
香蕉50

现要求按照价格从高到低的顺序,对上表里的商品进行排序,让价格相同的商品位次也一样,而紧接着它们的商品则有两种排序方法,一种是跳过之后的位次,另一种是不跳过之后的位次。得到的结果如下:

namepricerank_1rank_2
橘子10011
西瓜8022
苹果5033
香蕉5033
葡萄5033
柠檬3064

解决方法:
使用窗口函数可以轻松解决该问题:

SELECT name, price
       RANK() OVER(ORDER BY price DESC) AS rank_1,
       DENSE_RANK() OVER(ORDER BY price DESC) AS rank_2
FROM sample3_4_1
ORDER BY rank_1;

使用非等值自连接实现如下:

SELECT t1.name,
	   t1.price,
	  (SELECT COUNT(t2.price)
	   FROM sample3_4_1 t2
	   WHERE t2.price > t1.price) + 1 AS rank_1,
	  (SELECT COUNT(DISTINCT t3.price)
	   FROM sample3_4_1 t3
	   WHERE t3.price > t1.price) + 1 AS rank_2
FROM sample3_4_1 t1
ORDER BY rank_1;

-- 也可以如下实现
SELECT t1.name,
	   MAX(t1.price) AS price,
	   COUNT(t2.name) +1 AS rank_1,
	   COUNT(DISTINCT t2.name) +1 AS rank_2
FROM sample3_4_1 t1 LEFT OUTER JOIN sample3_4_1 t2
	 ON t1.price < t2.price
GROUP BY t1.name
ORDER BY rank_1;

与多表之间进行的普通连接相比,自连接的性能开销更大(特别是与非等值连接结合使用的时候),因此用于自连接的列推荐使用主键或者在相关列上建立索引。

问题2:
假设有如下相对于问题1表格增加了“地区”列的表格sample3_4_2:

district(地区)name(商品名称)price(价格)
东北橘子100
东北苹果50
东北葡萄50
东北柠檬30
关东柠檬100
关东菠萝100
关东苹果100
关东葡萄70
关西柠檬70
关西西瓜30
关西苹果20

现要求按照价格由高到低的顺序进行排序,如果出现相同位次,就跳过之后的位次,最后结果如下所示:

districtnamepricerank_1
东北橘子1001
东北苹果502
东北葡萄502
东北柠檬304
关东柠檬1001
关东菠萝1001
关东苹果1001
关东葡萄704
关西柠檬701
关西西瓜302
关西苹果203

解决方法:
该问题的解决方法依旧分为窗口函数和非等值自连接两种:

--窗口函数
SELECT district, name, price
       RANK() OVER(PARTITION BY district ORDER BY price DESC) AS rank_1
FROM sample3_4_2;

-- 非等值自连接
SELECT district, name, price,
       (SELECT COUNT(*) 
        FROM sample3_4_2 t2
        WHERE t1.district = t2.district
        AND t2.price > t1.price
       ) + 1 AS rank_1
FROM sample3_4_2 t1;

SELECT t1.district, t1.name,
       MAX(t1.price) AS price,
       COUNT(t2.name) + 1 AS rank_1
FROM sample3_4_2 t1 LEFT JOIN sample3_4_2 t2
ON t1.district = t2.district AND t1.price < t2.price
GROUP BY t1.district, t1.name;

问题3:
假设有如下相对于问题1表格增加了地区空列的表格sample3_4_3:

districtnamepriceranking
东北橘子100
东北苹果50
东北葡萄50
东北柠檬30
关东柠檬100
关东菠萝100
关东苹果100
关东葡萄70
关西柠檬70
关西西瓜30
关西苹果20

现要求往这个列中写入对应的位次,即更新列而非添加列,最终的结果与问题2结果相同。

解决方法:

-- 窗口函数
UPDATE sample3_4_3 
SET ranking = RANK() OVER(PARTITION BY district ORDER BY price DESC);

-- 非等值自连接
UPDATE sample3_4_3 t1
SET ranking = (SELECT COUNT(*) + 1
	           FROM sample3_4_3 t2
	           WHERE t1.district = t2.district
	           AND t2.price > t1.price
	          );

注意,有些数据库可能不支持在 SET 子句中使用窗口函数,所以它们无法正确执行相应代码,可以先用一阶子查询包装一下:

-- 使用一阶子查询包装的窗口函数
UPDATE sample3_4_3 t1
SET ranking =
		   (SELECT t2.ranking
			FROM (SELECT district, name,
			      RANK() OVER(PARTITION BY district ORDER BY price DESC) AS ranking
				  FROM sample3_4_3) t2
			WHERE t2.district = t1.district
			AND t2.name = t1.name);

4. 三值逻辑和 NULL

在对 SQL 语句进行性能优化时,经常用到的一个技巧是将 IN 等价改写成 EXISTS。问题在于,将 NOT IN 改写成 NOT EXISTS 时,结果未必一样。

4.1 NOT IN 和 NOT EXISTS 的不等价性

问题:
假设有如下表格sample4_1_1:

name(名字)age(年龄)city(住址)
布朗22东京
拉里19埼玉
伯杰21千叶

和表格sample4_1_2:

name(名字)age(年龄)city(住址)
齐藤22东京
田尻23东京
山田东京
和泉18千叶
武田20千叶
石川19神奈川

现要求根据这两张表查询“与住在东京的 sample4_1_2 学生年龄不同的 sample4_1_1 学生”。也就是说,希望查询到的是拉里和伯杰。

解决方法:
按照如下语句使用 NOT IN 谓词将查询不到任何记录:

SELECT *
FROM sample4_1_1
WHERE age NOT IN ( SELECT age
				   FROM sample4_1_2
				   WHERE city = '东京');

可以看出,如果 NOT IN 子查询中用到的表里被选择的列中存在 NULL ,则 SQL 语句整体的查询结果永远是空。

为了得到正确的结果,需要使用 EXISTS 谓词:

SELECT *
FROM sample4_1_1 t1
WHERE NOT EXISTS ( SELECT *
				   FROM sample4_1_2 t2
				   WHERE t1.age = t2.age AND t2.city = '东京');

因为 EXISTS 谓词永远不会返回 unknown,只会返回 true 或者 false 。因此就有了 IN 和 EXISTS 可以互相替换使用,而 NOT IN 和 NOT EXISTS 却不可以互相替换的混乱现象。

4.2 限定谓词

知识点
SQL 里有 ALL 和 ANY 两个限定谓词,用法如下:

-- ANY 关键字
-- 假设 any 内部的查询语句返回的结果个数是三个,如:result1、result2、result3
select ... from ... where a > any(...);
-- 等价于
select ... from ... where a > result1 or a > result2 or a > result3;
-- 即 a 大于子查询中的任意一个即可,等同于 a 大于子查询的最小值。

-- ALL关键字
-- ALL 关键字与 any 关键字类似,只不过上面的 or 改为 and:
select ... from ... where a > all(...);
-- 等价于
select ... from ... where a > result1 and a > result2 and a > result3;
-- 即 a 大于子查询中的每一个,等同于a大于子查询的最大值。

问题:
假设有如下表格sample4_2_1:

name(名字)age(年龄)city(住址)
布朗22东京
拉里19埼玉
伯杰21千叶

和表格sample4_2_2:

name(名字)age(年龄)city(住址)
齐藤22东京
田尻23东京
山田20东京
和泉18千叶
武田20千叶
石川19神奈川

现要求根据这两张表查询“与住在东京的所有 sample4_2_2 学生年龄都小的 sample4_1_1 学生”。也就是说,希望查询到的是拉里。

解决方法:
可以使用 ALL 谓词进行如下实现:

SELECT *
FROM sample4_2_1
WHERE age < ALL ( SELECT age
				  FROM sample4_2_2
				  WHERE city = '东京');

但是,如果山田的年龄像4.1节那样为 NULL,导致限定谓词中出现了 NULL,那么此时返回记录为空。可以看出,限定谓词任一参数为 NULL 则结果为 NULL。

注意,限定谓词和极值函数(MAX、MIN)不是等价的,比较对象原本就不存在时(即输入为空表或空集时),ALL 限定谓词会返回所有结果,而极值函数会返回 NULL,极值函数会自动忽略 NULL。实际上,当输入为空表时返回 NULL 的不只是极值函数, COUNT 以外的聚合函数也是如此。

5. HAVING子句

WHERE 子句用来调查集合元素的性质,而 HAVING 子句用来调查集合本身的性质。

如果实体对应的是表中的一行数据,那么该实体应该被看作集合中的元素,因此指定查询条件时应该使用 WHERE 子句。如果实体对应的是表中的多行数据,那么该实体应该被看作集合,因此指定查询条件时应该使用 HAVING 子句。

5.1 寻找缺失的编号

问题1:
假设有如下带有“连续编号”列的表格sample5_1_1:

seq(连续编号)name(名字)
1迪克
2
3莱露
5
6玛丽
8

现要求查询这张表里“连续编号”列是否存在数据缺失。

解决方法:
对于这种起始编号为1的列,只需计算用 COUNT(*) 统计出来的行数是否等于“连续编号”列的最大值,即可判断编号是否缺失:

SELECT '存在缺失的编号' AS gap
FROM sample5_1_1
HAVING COUNT(*) <> MAX(seq);

HAVING 子句是可以单独使用的,可以认为是对空字段进行了 GROUP BY 操作,只不过省略了 GROUP BY 子句。

进一步,可以像下面这样查出来缺失编号的最小值:

SELECT MIN(seq + 1) AS gap
FROM sample5_1_1
WHERE (seq+ 1) NOT IN ( SELECT seq FROM sample5_1_1);
  • 如果没有缺失的编号,则查询到的结果是最大编号的下一个编号。
  • 如果表 sample5_1_1 里包含 NULL ,那么这条 SQL 语句的查询结果为空。
  • 这个查询不够周全,并不能涵盖所有情况。例如,如果表里没有编号 1,那么缺失编号的最小值应该是 1,但是这条 SQL 语句不能得出正确的结果。

问题2:
考虑一种更一般的情况,“连续编号”列起始编号不一定为1,假设有如下表格sample5_1_2:

seq(连续编号)name(名字)
2迪克
3
4莱露
5
6玛丽
8

现仍旧要求查询上表中“连续编号”列是否存在数据缺失。

解决方法:
如果数列的最小值和最大值之间没有缺失的编号,它们之间包含的元素的个数应该是“最大值-最小值+ 1”:

SELECT '存在缺失的编号' AS gap
FROM sample5_1_2
HAVING COUNT(*) <> MAX(seq) - MIN(seq) + 1 ;

如果不论是否存在缺失的编号,都想要返回结果,那么只需要把条件写到 SELECT 里即可:

SELECT CASE WHEN COUNT(*) = 0
			THEN '表为空'
			WHEN COUNT(*) <> MAX(seq) - MIN(seq) + 1
			THEN '存在缺失的编号'
			ELSE '连续' END AS gap
FROM sample5_1_2;

查找最小的缺失编号的 SQL 语句改进如下:

SELECT CASE WHEN COUNT(*) = 0 OR MIN(seq) > 1 THEN 1
			ELSE (SELECT MIN(seq +1)
				  FROM sample5_1_2 S1
				  WHERE NOT EXISTS
					   (SELECT *
						FROM sample5_1_2 S2
						WHERE S2.seq = S1.seq + 1)) END
FROM sample5_1_2;

5.2 求众数和中位数

问题:
假设有如下表格sample5_2:

name(名字)income(收入)
桑普森400000
迈克30000
怀特20000
阿诺德20000
史密斯20000
劳伦斯15000
哈德逊15000
肯特10000
贝克10000
斯科特10000

对该表格求取平均值并不能很好地反正其中人们的收入水平,因为该表格中存在离群点。这种时候需要使用更能准确反映出群体趋势的指标——众数和中位数。

解决方法:
求众数的思路很简单,就是将收入相同的人汇总到一个集合里,然后从汇总后的各个集合里找出元素个数最多的集合:

-- 使用限定谓词
SELECT income, COUNT(*) AS cnt
FROM sample5_2
GROUP BY income
HAVING COUNT(*) >= ALL(SELECT COUNT(*)
					   FROM sample5_2
					  GROUP BY income);
-- 极值函数
SELECT income, COUNT(*) AS cnt
FROM sample5_2
GROUP BY income
HAVING COUNT(*) >= (SELECT MAX(cnt)
					FROM (SELECT COUNT(*) AS cnt
						  FROM sample5_2
						  GROUP BY income) TMP) ;

注意:

  • 不能直接将 ALL 换成 MAX,MAX是行方向取最大值,不是列方向。
  • 子查询中 FROM 子句必须起别名,否则 MySQL 会报错。

求中位数的思路是:将集合里的元素按照大小分为上半部分 S1 和下半部分 S2 两个子集,同时让这 2 个子集共同拥有集合正中间的元素。这样,共同部分的元素的平均值就是中位数。代码实现如下:

-- 非等值自连接
SELECT AVG(DISTINCT income)
FROM (SELECT T1.income
	  FROM sample5_2 T1 CROSS JOIN sample5_2 T2
	  GROUP BY T1.income
	  HAVING SUM(CASE WHEN T2.income >= T1.income THEN 1 ELSE 0 END) >= COUNT(*)/2
	  AND SUM(CASE WHEN T2.income <= T1.income THEN 1 ELSE 0 END) >= COUNT(*)/2 ) TMP;

上述 SQL 语句的要点在于比较条件>= COUNT(*)/2里的等号,加上等号并不是为了清晰地分开子集 S1 和 S2,而是为了让这 2 个子集拥有共同部分。如果去掉等号,将条件改成> COUNT(*)/2,那么当元素个数为偶数时,S1 和 S2 就没有共同的元素了,也就无法求出中位数了。此外,外层的 AVG 函数也是考虑了元素个数为偶数的情况。

5.3 查询不包含 NULL 的集合(特征函数)

问题:
假设有如下表格sample5_3:

student_id(学号 ID)dpt(学院)sbmt_date(提交日期)
100理学院2005-10-10
101理学院2005-09-22
102文学院
103文学院2005-09-10
200文学院2005-09-22
201工学院
202经济学院2005-09-25

学生提交报告后,“提交日期”列会被写入日期,而提交之前是 NULL 。现在要求从这张表里找出哪些学院的学生全部都提交了报告(即理学院、经济学院)。

解决方法:

-- 使用 COUNT 函数
SELECT dpt
FROM sample5_3
GROUP BY dpt
HAVING COUNT(*) = COUNT(sbmt_date);

-- 使用 CASE 表达式
SELECT dpt
FROM sample5_3
GROUP BY dpt
HAVING COUNT(*) = SUM(CASE WHEN sbmt_date IS NOT NULL THEN 1 ELSE 0 END);

上述语句中,CASE 表达式的作用相当于进行判断的函数,用来判断各个元素( 行)是否属于满足了某种条件的集合,这样的函数称为特征函数(characteristic function)。

5.4 精确关系除法

问题:
假设有如下表格sample5_4_1:

item(商品)
啤酒
纸尿裤
自行车

和表格sample5_4_2:

shop(店铺)item(商品)
仙台啤酒
仙台纸尿裤
仙台自行车
仙台窗帘
东京啤酒
东京纸尿裤
东京自行车
大阪电视
大阪纸尿裤
大阪自行车

现要求查询在表 sample5_4_2 中包含且仅包含表 sample5_4_1 中所有商品的店铺,即东京店铺。这类问题被称为“精确关系除法”(exact relational division),即只选择没有剩余商品的店铺。

解决方法:
类比 2.2 节,可以轻松实现查询在表 sample5_4_2 中包含了(不要求仅包含)表 sample5_4_1 中所有商品的店铺:

SELECT t2.shop
FROM sample5_4_2 t2 INNER JOIN sample5_4_1 t1
ON t2.item = t1.item
group by t2.shop
having count(*) = (select count(*) from sample5_4_1);

使用外连接即可解决精确关系除法:

SELECT t2.shop
FROM sample5_4_2 t2 LEFT JOIN sample5_4_1 t1
ON t2.item = t1.item
group by t2.shop
having count(t2.item) = (select count(*) from sample5_4_1)
AND count(t1.item) = (select count(*) from sample5_4_1);

5.5 全称量化和存在量化的转换

问题:
假设有如下表格sample5_5:

member(队员)team_id(队伍编号 ID)status(状态)
1待命
1出勤中
米克1待命
卡伦2出勤中
凯斯2休息
3待命
哈特3待命
迪克3待命
贝斯4待命
阿伦5出勤中
罗伯特5休息
卡根5待命

现要求查询当前可以出勤的队伍,可以出勤即队伍里所有队员都处于“待命”状态。

解决方法:
“所有队员都处于‘待命’状态”这个条件是全称量化命题,可以转换为存在量化命题——“不存在不处于待命状态的队员”,因此可以用 NOT EXISTS 来表达:

SELECT team_id, member
FROM sample5_5 T1
WHERE NOT EXISTS
		(SELECT *
		 FROM sample5_5 T2
		 WHERE T1.team_id = T2.team_id AND status <> '待命');

使用 HAVING 子句实现非常简单:

SELECT team_id
FROM sample5_5 
GROUP BY team_id
HAVING COUNT(*) = SUM(CASE WHEN status = '待命' THEN 1 ELSE 0 END);

-- 也可以这么写,前提是 status 列没有 NULL
SELECT team_id
FROM sample5_5 
GROUP BY team_id
HAVING MAX(status) = '待命' AND MIN(status) = '待命'; 

也可以把条件放在 SELECT 子句里,以列表形式显示出各个队伍是否所有队员都在待命:

SELECT team_id,
	   CASE WHEN MAX(status) = '待命' AND MIN(status) = '待命'
			THEN '全都在待命'
			ELSE '队长!人手不够' END AS status
FROM sample5_5 
GROUP BY team_id;

5.6 存在重复数据的集合——多重集合

问题1:
假设有如下表格sample5_6_1:

center(生产地)receive_date(入库日期)material(材料)
东京2007-4-01
东京2007-4-12
东京2007-5-17
东京2007-5-20
大阪2007-4-20
大阪2007-4-22
大阪2007-4-29
名古屋2007-3-15
名古屋2007-4-01
名古屋2007-4-24
名古屋2007-5-02
名古屋2007-5-10
福冈2007-5-10
福冈2007-5-28

现要求查询重复入库过同一材料的生产地,即东京和名古屋。

解决方法:
用 HAVING 子句实现起来很简单:

SELECT center
FROM sample5_6_1
GROUP BY center
HAVING COUNT(material) <> COUNT(DISTINCT material);

也可以通过将 HAVING 改写成 EXISTS 的方式来解决该问题,用 EXISTS 改写后的 SQL 语句也能够查出重复的具体是哪一种材料:

SELECT center, material
FROM sample5_6_1 M1
WHERE EXISTS
	   (SELECT *
		FROM sample5_6_1 M2
		WHERE M1.center = M2.center
		AND M1.receive_date <> M2.receive_date
		AND M1.material = M2.material);

相反,如果想要查出不存在重复材料的生产地及材料有哪些,只需要把 EXISTS 改写为 NOT EXISTS 就可以了。

整理一下在调查集合性质时经常用到的条件:

Nov条件表达式用途
1COUNT (DISTINCT col) = COUNT (col)col 列没有重复的值
2COUNT(*) = COUNT(col)col 列不存在 NULL
3COUNT(*) = MAX(col)col 列是连续的编号(起始值是 1)
4COUNT(*) = MAX(col) - MIN(col) + 1col 列是连续的编号(起始值是任意整数)
5MIN(col) = MAX(col)col 列都是相同值,或者是 NULL
6MIN(col) * MAX(col) > 0col 列全是正数或全是负数
7MIN(col) * MAX(col) < 0col 列的最大值是正数,最小值是负数
8MIN(ABS(col)) = 0col 列最少有一个是 0
9MIN(col - 常量 ) = - MAX(col - 常量 )col 列的最大值和最小值与指定常量等距

问题2:
在问题1中我们只针对 material 一个字段检查了集合中是否存在重复值,现在考虑针对多个字段检查是否存在重复。

在表 sample5_6_1 的基础上添加“原产国”字段,得到下表 sample5_6_2:

center( 生产地 )receive_date(入库日期)material(材料)orgland( 原产国 )
东京2007-04-01智利
东京2007-04-12泰国
东京2007-05-17巴西
东京2007-05-20泰国
大阪2007-04-20澳大利亚
大阪2007-04-22南非
大阪2007-04-29印度
名古屋2007-03-15玻利维亚
名古屋2007-04-01智利
名古屋2007-04-24阿根廷
名古屋2007-05-02智利
名古屋2007-05-10泰国
福冈2007-05-10美国
福冈2007-05-28俄罗斯

现要求从上表中查出材料和原产国两个字段都重复的生产地。答案只有(锌, 泰国)重复了的东京。对于名古屋,如果只看材料,那么“钢”有重复,但是因为产地分别是智利和阿根廷,所以应该被排除在外。

解决方法:
对于这一问题最先想到的解决方法是:

SELECT center
FROM sample5_6_2
GROUP BY center
HAVING COUNT(material, orgland) <> COUNT(DISTINCT material, orgland);

但是实际上,COUNT 函数的参数只能是一列,我们可以将多列拼凑成一列作为参数传递:

SELECT center
FROM sample5_6_2
GROUP BY center
HAVING COUNT(material || orgland) <> COUNT(DISTINCT material || orgland);

如果想要拼凑的字段不是字符串类型,可以先转换成字符串类型再拼凑。需要扩展成三个字段以上时,同样地拼凑起来就可以了。

下面这种解法也是错误的:

SELECT center
FROM sample5_6_2
GROUP BY center
HAVING COUNT(material) <> COUNT(DISTINCT material)
AND COUNT(orgland) <> COUNT(DISTINCT orgland);
  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
MyBatis是一款优秀的持久层框架,它支持使用动态SQL语句,灵活地构建SQL语句,使得开发者能够更加方便地实现复杂的业务逻辑。下面介绍一些MyBatis动态SQL使用技巧: 1. if标签 if标签用于判断条件是否成立,从而决定是否拼接SQL语句。if标签的语法如下: ```xml <select id="findUsers" resultType="User"> select * from user <where> <if test="username != null"> and username = #{username} </if> <if test="email != null"> and email = #{email} </if> </where> </select> ``` 上面的代码中,如果传入的username或email不为null,就会拼接相应的SQL语句。这样可以避免拼接过多的无用SQL语句。 2. choose、when、otherwise标签 choose、when、otherwise标签用于实现多重条件判断。当条件成立时,执行对应的SQL语句。choose、when、otherwise标签的语法如下: ```xml <select id="findUsers" resultType="User"> select * from user <where> <choose> <when test="username != null"> and username = #{username} </when> <when test="email != null"> and email = #{email} </when> <otherwise> and 1=1 </otherwise> </choose> </where> </select> ``` 上面的代码中,如果传入的username不为null,就会执行第一个when标签中的SQL语句;如果传入的email不为null,就会执行第二个when标签中的SQL语句;如果传入的username和email都为null,就会执行otherwise标签中的SQL语句。 3. foreach标签 foreach标签用于遍历集合,将集合中的元素拼接成SQL语句。foreach标签的语法如下: ```xml <update id="batchUpdate"> update user set username = #{username}, email = #{email} where id in <foreach collection="ids" item="id" separator="," open="(" close=")"> #{id} </foreach> </update> ``` 上面的代码中,将传入的ids集合中的元素拼接成SQL语句,实现批量更新操作。 4. bind标签 bind标签用于将表达式的值绑定到指定的变量上。bind标签的语法如下: ```xml <select id="findUsers" resultType="User"> <bind name="pattern" value="'%' + username + '%'"/> select * from user where username like #{pattern} </select> ``` 上面的代码中,将传入的username值绑定到pattern变量上,并将pattern变量拼接到SQL语句中,实现模糊查询操作。 以上是MyBatis动态SQL使用的一些技巧,可以帮助开发者更加灵活地构建SQL语句,实现复杂的业务逻辑。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值