从数据库原理实验报告到SQL Server自动验证练习库
简介:面向高校数据库原理课程学习者,尤其适合大数据技术等专业学生对照完成SQL Server实验与课程报告。内容以南华大学数据库原理A实验为主线,依次覆盖认识DBMS、通过管理工具和SQL语句创建数据库与三张数据表、定义主码外码默认值及表间关系,并延伸到单表查询、多表连接查询、高级SQL查询和数据更新等训练,围绕学生选课场景给出实验题目、实验要求、参考代码与实验总结,对主外码约束和数据完整性等课程重点也有所体现,便于还原从建库建表到查询操作的完整过程。压缩包内含1个doc文件,约5.38MB,按实验1至实验4及总结组织,目录清晰,可快速定位相应代码与结果记录。目前已有377人学习,适合需要参考SQL语句写法、核对实验步骤或查漏补缺的读者使用。
1. 一份数据库原理实验报告,能当 SQL Server 的练手底座用
很多人拿到「数据库原理实验报告」这类资料,第一反应是交作业用的,随手存进网盘就再没打开。我这次翻这份资料时换了个看法:它其实是一条从建库建表到单表查询、多表连接、嵌套与组合查询、再到数据更新的完整链路,四个实验串起来正好覆盖 SQL Server 上一套最小可用的操作面。三张表——学生、课程、选修——看着简单,却把主码、外码、默认值、空值、自连接、左右连接、集合运算这些真正容易出错的点全踩了一遍。适合两类人:刚学完 SQL 语法但写不出完整查询的在校生,以及工作里临时要接数据库活儿、需要一套能反复跑的样例库做验证的后端同学。
2. 库表设计:三张表怎么落地成可直接执行的 DDL
2.1 实验报告里的文件组织与环境准备
这份资料的原始形态是一份按实验序号排布的实验报告:实验 1 认识 DBMS,实验 2 简单 SQL 查询,实验 3 高级 SQL 查询,实验 4 数据的更新,最后一段实验总结。每个实验下面再分「实验题目 / 实验要求 / 实验代码」三块,题目是一堆中文自然语言描述的需求,代码部分贴着截图和 SQL 片段。真正有价值的其实只有两块:题目的需求描述,以及能被验证的 SQL 语句,中间那些截图对复现毫无帮助。
我把它重组成可直接执行的工程结构。原报告里表名是中文加数字后缀,为了脚本能反复跑、不跟学号绑死,统一改成Student、Course、SC三个英文短名,库名固定为StuCou。目录按「一次执行到底」的顺序排:
stucou-lab/ ├─ sql/ │ ├─ 00_drop.sql -- 可重复执行:先删后建 │ ├─ 01_schema.sql -- 建库 + 三张表 + 约束 │ ├─ 02_seed.sql -- 初始数据,固定主码,避免随机 │ ├─ 03_single.sql -- 实验 2 单表查询 10 题 │ ├─ 04_join.sql -- 实验 2 多表连接 8 题 │ ├─ 05_nested.sql -- 实验 3 嵌套与集合运算 6 题 │ └─ 06_dml.sql -- 实验 4 增删改 + 事务 └─ README.md环境上没什么讲究,SQL Server Express 或 Developer 版都行,配 SQL Server Management Studio(下称 SSMS)就能跑。我一般用 Developer 版,因为 Express 单库 10GB 的上限虽然够用,但 Developer 版带完整的功能集,日后想加索引视图、试用执行计划缓存都不用换实例。装完确认两件事:实例名记牢(默认localhost或.\SQLEXPRESS),排序规则选Chinese_PRC_CI_AS或至少是支持中文的版本,不然后面插中文姓名会给你显示成一串问号,这个问题在第 5 章会专门讲。
2.2 三张表的结构定稿与字段取舍
原报告对字段只写了中文名和一句「为属性选择合适的数据类型」,具体类型得自己定。这一步是整套实验里最容易被低估的部分,类型选错,后面写查询时到处要CAST,非常难受。定稿后的结构是这样:
| 表 | 字段 | 类型 | 约束说明 |
|---|---|---|---|
| Student | Sno | CHAR(8) | 主码,学号定长 |
| Student | Sname | NVARCHAR(20) | 非空 |
| Student | Ssex | NCHAR(1) | 默认 N'男',CHECK 限定男女 |
| Student | Sbirth | DATE | 可空 |
| Student | Sdept | NVARCHAR(30) | 可空 |
| Student | Smemo | NVARCHAR(100) | 可空,备注 |
| Course | Cno | CHAR(3) | 主码 |
| Course | Cname | NVARCHAR(30) | 非空 |
| Course | Cpno | CHAR(3) | 外码,指向本表 Cno,可空 |
| Course | Ccredit | TINYINT | 默认 2,CHECK 1~10 |
| SC | Sno | CHAR(8) | 联合主码之一,外码指向 Student |
| SC | Cno | CHAR(3) | 联合主码之一,外码指向 Course |
| SC | Grade | SMALLINT | 可空,CHECK 0~100 |
几个取舍值得说清楚。学号用CHAR(8)而不是VARCHAR,因为学号长度固定,定长类型在比较和索引上更省事;姓名用NVARCHAR是为了中文,VARCHAR在某些排序规则下存中文会出现截断或乱码,这是踩过的坑。成绩用SMALLINT而非DECIMAL,因为原始题目里分数都是整数,但如果老师要求保留一位小数,就换成DECIMAL(4,1),改的时候记得同步改 CHECK 的范围。先行课Cpno是个自引用外码,指向Course自己的Cno,这是整个设计里唯一有点绕的地方,也是「间接先行课」那道题能成立的基础。
提示:
Cpno允许为空,对应「这门课没有先行课」的语义。不要用 0 或空串表示「无」,那样外码约束会直接报错,而且IS NULL判断也没法写。
2.3 用 T-SQL 建库建表:主码、外码、默认值一次到位
建表顺序有硬性要求:被引用的表必须先存在,所以Student和Course在前,SC在后。Course.Cpno的自引用外码我习惯建完表再单独ALTER加,这样执行顺序一眼能看清,出错时也容易定位到是哪一条约束挂了。
-- 00_drop.sql:让整个脚本可以反复执行 USE master; GO IF DB_ID(N'StuCou') IS NOT NULL BEGIN ALTER DATABASE StuCou SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE StuCou; END GO这段的意义在于「可重复」。实验报告是交一次就完事的,但只要是工程代码,就必须能反复跑。SINGLE_USER WITH ROLLBACK IMMEDIATE是血泪经验,如果还有连接挂在库上,直接DROP DATABASE会一直卡住,加上这句会强制断开。执行完再建库:
-- 01_schema.sql CREATE DATABASE StuCou; GO USE StuCou; GO CREATE TABLE Student ( Sno CHAR(8) NOT NULL CONSTRAINT PK_Student PRIMARY KEY, Sname NVARCHAR(20) NOT NULL, Ssex NCHAR(1) NOT NULL CONSTRAINT DF_Student_Ssex DEFAULT N'男' CONSTRAINT CK_Student_Ssex CHECK (Ssex IN (N'男', N'女')), Sbirth DATE NULL, Sdept NVARCHAR(30) NULL, Smemo NVARCHAR(100) NULL ); GO CREATE TABLE Course ( Cno CHAR(3) NOT NULL CONSTRAINT PK_Course PRIMARY KEY, Cname NVARCHAR(30) NOT NULL, Cpno CHAR(3) NULL, -- 先行课,指向本表 Cno Ccredit TINYINT NOT NULL CONSTRAINT DF_Course_Ccredit DEFAULT 2 CONSTRAINT CK_Course_Ccredit CHECK (Ccredit BETWEEN 1 AND 10) ); GO CREATE TABLE SC ( Sno CHAR(8) NOT NULL, Cno CHAR(3) NOT NULL, Grade SMALLINT NULL CONSTRAINT CK_SC_Grade CHECK (Grade BETWEEN 0 AND 100), CONSTRAINT PK_SC PRIMARY KEY (Sno, Cno), CONSTRAINT FK_SC_Student FOREIGN KEY (Sno) REFERENCES Student(Sno), CONSTRAINT FK_SC_Course FOREIGN KEY (Cno) REFERENCES Course(Cno) ); GO -- 自引用外码单独加,建表顺序更清楚 ALTER TABLE Course ADD CONSTRAINT FK_Course_Cpno FOREIGN KEY (Cpno) REFERENCES Course(Cno); GO参数和写法上讲三点。第一,主码用CONSTRAINT PK_xxx显式命名,好处是后面报错信息里能直接看到约束名,不用去猜系统生成的PK__Student__xxxx是哪个。第二,默认值DF_Student_Ssex DEFAULT N'男'里的N前缀不能省,NCHAR/NVARCHAR列加中文默认值时,不加N会按当前数据库排序规则转换,轻则存进去变成?,重则直接截断。第三,SC的主码是(Sno, Cno)联合主码,这直接保证了「一个学生对一门课只能有一条成绩记录」,比在应用层写去重逻辑可靠得多。
2.4 图形界面与脚本两条路:先跑通再谈效率
原报告实验 1 要求「利用 SQL Server Management Studio 创建数据库、表和表间关系」,然后「利用 SQL 语句创建」一遍,两条路都要走。这个设计其实挺合理:GUI 那条路是为了让你看见表间关系图长什么样,脚本那条路是为了让操作可复现。实际做的时候我建议反着来——先跑脚本建库,再用 SSMS 打开「数据库关系图」验证外码连得对不对。
在 SSMS 里展开StuCou→ 「数据库关系图」→ 新建,会看到SC分别拉了线到Student和Course,Course自己还画了个圈。如果图里只有两条线,说明自引用外码没加上,回头执行ALTER TABLE那一段补上。这个验证方法比读 SQL 快得多,尤其是表多起来之后,肉眼扫关系图能立刻发现「这个外码是不是指错表了」。
图形界面建表的另一个用处是确认默认值约束真的挂上去了。在对象资源管理器里展开Student→ 约束,应该能看到DF_Student_Ssex和CK_Student_Ssex两个节点。有些时候脚本执行没报错,但约束因为批次(GO)分隔问题没生效,这种静默失败最难查,花十秒看一眼约束列表比事后调试半小时划算。
3. 单表查询拆解:十个题目背后的四个固定套路
3.1 条件过滤:比较运算符与逻辑运算符的组合
实验 2 的单表查询十道题,摊开看其实就四类:按值过滤、按模式匹配、聚合分组、排序取前 N。前三道都是最朴素的WHERE过滤。查询计算机系全体学生:
SELECT Sno, Sname, Ssex, Sbirth, Sdept, Smemo FROM Student WHERE Sdept = N'计算机系';这行没什么技术含量,但有个细节值得停一下:Sdept = N'计算机系'里的N前缀。如果Sdept是NVARCHAR,而你写成Sdept = '计算机系',SQL Server 会把字面量按数据库默认排序规则转成VARCHAR再比较,在某些排序规则下会发生隐式转换,导致索引失效,数据量一大就慢得离谱。这类问题在小样本表上看不出来,等你把表灌到十万行才发现,那时候改就晚了。
姓李的学生用LIKE:
SELECT Sno, Sname FROM Student WHERE Sname LIKE N'李%';LIKE N'李%'匹配以「李」开头的姓名,%是任意长度通配符。这里有个反直觉的点:LIKE '%李%'前导通配符会让索引失效,但如果只是查姓氏,LIKE N'李%'这种前缀匹配在某些索引下还能走范围扫描。另外注意LIKE的通配符还有_(单字符)和[](字符集),姓名里如果有_或%会被当成通配符,严谨写法是用ESCAPE子句转义,虽然姓名里不太可能出现,但知道这回事就行。
3.2 空值与模糊匹配:IS NULL 和 LIKE 的边界
第三题「查询课程表中没有先行课的课程名」,考的是空值判断:
SELECT Cname FROM Course WHERE Cpno IS NULL;这里绝不能写Cpno = NULL。SQL 里的NULL表示「未知」,任何与它做比较运算的结果都是UNKNOWN,WHERE只会放行结果为TRUE的行,所以Cpno = NULL永远返回空集。这是个经典陷阱,几乎每届都有人踩。同理,Cpno <> 'C01'也不会把Cpno为NULL的行选出来,要写Cpno <> 'C01' OR Cpno IS NULL,或者用ISNULL(Cpno, '') <> 'C01'。
第四题查考试成绩有不及格的学生的学号:
SELECT DISTINCT Sno FROM SC WHERE Grade < 60;DISTINCT不能省。一个学生可能挂了两门课,SC里就有两条记录,不去重的话同一个学号会出现两次。这类「一个实体多条明细」的场景,去重是最容易漏的一步,写完先看一眼结果行数对不对,比事后查为什么报表数字翻倍要快。
第五题查选修了 C01 或 C02 的学生学号及成绩:
SELECT Sno, Grade FROM SC WHERE Cno IN ('C01', 'C02');IN在这里等价于Cno = 'C01' OR Cno = 'C02',但写起来短,而且列表长了之后IN更好维护。如果条件里同时有AND,记得加括号:WHERE Cno IN ('C01','C02') AND Grade >= 60没问题,但WHERE Cno = 'C01' OR Cno = 'C02' AND Grade >= 60会因为AND优先级高于OR变成「C01 全要,或者 C02 且及格」,跟预期差很远。
3.3 聚合与分组:GROUP BY + HAVING 解决「两门以上」
第六题查计算机系学生的姓名和年龄:
SELECT Sname, YEAR(GETDATE()) - YEAR(Sbirth) AS Age FROM Student WHERE Sdept = N'计算机系';用YEAR()相减算年龄是粗糙算法,生日没到的会多算一岁,严谨做法是用DATEDIFF配合月份判断,但实验场景下够用。要注意的是,如果在WHERE里对Sbirth用函数,比如WHERE YEAR(Sbirth) BETWEEN 1999 AND 2000,会导致Sbirth上的索引失效,正确写法是范围比较:
SELECT Sname FROM Student WHERE Sdept = N'计算机系' AND Sbirth >= '1999-01-01' AND Sbirth < '2001-01-01'; -- 左闭右开,边界不会漏左闭右开这个写法在日期范围查询里是标准做法。用BETWEEN '1999-01-01' AND '2000-12-31'看似等价,但如果字段是DATETIME类型,2000-12-31会被补成00:00:00,当天下午出生的学生就漏了。虽然这里字段是DATE不会出问题,但养成左闭右开的习惯能省掉后面一堆麻烦。
第八题查姓李的学生中按字典序排前两个:
SELECT TOP 2 Sno, Sname FROM Student WHERE Sname LIKE N'李%' ORDER BY Sname;TOP必须跟ORDER BY一起用才有意义,没有排序的TOP 2取哪两行是不确定的,这种查询在生产里属于定时炸弹——今天跑出来是这两条,明天加了索引就变成另外两条。
第九、十题是GROUP BY的主场。第九题查选修两门以上课程的学生学号与课程数:
SELECT Sno, COUNT(*) AS Cnt FROM SC GROUP BY Sno HAVING COUNT(*) > 2;这里有个语义细节:题目说的是「两门以上」,中文里「以上」是否含本数有歧义。对照第十题写的是「大于等于 2」,说明第九题就是严格大于 2,用> 2。这种题面措辞的差异往往就是扣分点,写的时候顺手在注释里标一句判断依据,日后复查省事。
第十题查平均成绩和选课门数并按平均成绩降序:
SELECT Sno, AVG(Grade * 1.0) AS AvgGrade, COUNT(*) AS Cnt FROM SC GROUP BY Sno HAVING COUNT(*) >= 2 ORDER BY AVG(Grade * 1.0) DESC;Grade * 1.0这个写法必须解释。Grade是SMALLINT,AVG()对整数类型求平均时走的是整数除法,88 和 76 的平均会算成 82 而不是 82.0,小数部分被直接截断。乘个1.0强制转成DECIMAL就正常了。更规范的写法是CAST(Grade AS DECIMAL(5,2))或者AVG(CAST(Grade AS FLOAT)),效果一样但意图更清楚。另外ORDER BY里重复写了一遍聚合表达式,是因为 SQL Server 不允许在ORDER BY里直接用SELECT的别名配聚合函数做复杂排序,重复写最稳。
3.4 排序与取前 N 行:TOP 与 ORDER BY
单表这部分还有个小点值得单拎出来:多列排序。比如「按院系升序、同院系内按出生日期降序」:
SELECT Sno, Sname, Sdept, Sbirth FROM Student ORDER BY Sdept ASC, Sbirth DESC;ORDER BY后面跟多列时,排序优先级从左到右,第一列相同才看第二列。这里有个容易忽略的行为:NULL在排序里的位置取决于数据库,SQL Server 里ASC时NULL排最前,DESC时排最后。如果不想让空值干扰结果,用ORDER BY CASE WHEN Sbirth IS NULL THEN 1 ELSE 0 END, Sbirth DESC显式控制。实验数据量小看不出差别,但报表类需求里这个细节经常被提出来。
另外SELECT列表顺序和原报告题目里给的字段顺序最好保持一致,虽然 SQL 不要求,但对照答案检查时不用来回对字段名,纯粹是给自己省事。
4. 多表连接与嵌套查询:把三张表用到位
4.1 内连接与三表串联
多表查询的第一步是把JOIN写对。第一题查选修了「数据库原理」的计算机系学生的学号和姓名,需要串三张表:
SELECT DISTINCT s.Sno, s.Sname FROM Student s JOIN SC sc ON sc.Sno = s.Sno JOIN Course c ON c.Cno = sc.Cno WHERE s.Sdept = N'计算机系' AND c.Cname = N'数据库原理';这段的结构是「学生 → 选课 → 课程」一路连下去,中间SC是桥梁表。DISTINCT在这里依然不能省,因为一个学生如果重复修了同一门课(虽然主码限制了不能重复选,但换课程号的情况可能出现),结果会重复。JOIN默认是INNER JOIN,写不写INNER都一样,但显式写出来可读性更好。
第三题查学生的学号、姓名、选修课程名和成绩,是同一个套路:
SELECT s.Sno, s.Sname, c.Cname, sc.Grade FROM Student s JOIN SC sc ON sc.Sno = s.Sno JOIN Course c ON c.Cno = sc.Cno;注意这里的Grade可能为NULL(选了课还没出成绩),INNER JOIN不会把这些行过滤掉,因为连接条件只看Sno和Cno,不看Grade。但如果题目要求「只显示有成绩的记录」,就得加WHERE sc.Grade IS NOT NULL,这是个常见的需求差异点。
第八题查计算机系选课数大于 2 的学生姓名、平均成绩和选课门数,是连接加聚合的组合:
SELECT s.Sname, AVG(sc.Grade * 1.0) AS AvgGrade, COUNT(*) AS Cnt FROM Student s JOIN SC sc ON sc.Sno = s.Sno WHERE s.Sdept = N'计算机系' GROUP BY s.Sno, s.Sname HAVING COUNT(*) > 2 ORDER BY AVG(sc.Grade * 1.0) DESC;GROUP BY里必须带上Sno,不能只写Sname,因为姓名可能重名。这是分组查询的铁律:SELECT里出现的非聚合列,必须全部出现在GROUP BY里,否则报 8120 错误。GROUP BY s.Sno, s.Sname既满足语法,又保证结果唯一。
4.2 外连接:LEFT JOIN 保留「没选课的学生」
第五题要求「查询所有学生的信息和他们的选修课程编号」,关键词是「所有学生」。有些学生一门课都没选,用INNER JOIN会直接把他们过滤掉,这里必须用左连接:
SELECT s.Sno, s.Sname, s.Ssex, s.Sbirth, s.Sdept, s.Smemo, sc.Cno FROM Student s LEFT JOIN SC sc ON sc.Sno = s.Sno;LEFT JOIN以左表Student为基准,右表SC匹配不上就填NULL。没选课的学生会得到一行,Cno列是NULL。验证方法很简单:跑完之后看行数是不是大于等于Student的行数,如果相等说明所有人都选了课,样本数据不够典型。
第六题查所有课程的名字及被选修情况,逻辑相同,只是基准表换成Course:
SELECT c.Cname, sc.Sno, sc.Cno, sc.Grade FROM Course c LEFT JOIN SC sc ON sc.Cno = c.Cno;这道题是理解LEFT JOIN和INNER JOIN差异的好例子。如果数据里有一门新课还没人选,INNER JOIN会把它整个吞掉,LEFT JOIN则会输出一行Cname有值、其他列全NULL的记录。实际业务里「主表全量 + 关联表补充信息」是高频需求,这个模式要形成肌肉记忆。
4.3 自连接:查间接先行课
第二题「查询每一门课的间接先行课」,是整个实验里唯一需要自连接的地方,也是很多人第一次卡住的地方。间接先行课的意思是「先行课的先行课」,比如 C03 的先行课是 C02,C02 的先行课是 C01,那 C03 的间接先行课就是 C01。把Course表跟它自己连一次:
SELECT a.Cno AS 课程号, b.Cpno AS 间接先行课 FROM Course a JOIN Course b ON a.Cpno = b.Cno WHERE b.Cpno IS NOT NULL;a和b是同一张表的两个别名,a.Cpno = b.Cno表示「a 的先行课等于 b 的课程号」,此时b.Cpno就是「b 自己的先行课」,也就是 a 的间接先行课。WHERE b.Cpno IS NOT NULL过滤掉那些间接先行课不存在的记录,题目要求「有间接先行课才算」。
自连接的关键是别被「同一张表」吓住,把它当成两张结构一样的表,只是数据来源相同。别名必须起,不起别名 SQL Server 会报「对象名不明确」。如果还想查三级先行课,再加一个JOIN Course c ON b.Cpno = c.Cno就行,层级越深别名越多。实际业务里的组织架构树、商品分类树都是这个套路,只是层数不固定,得用递归 CTE 而不是手写连接。
4.4 嵌套与集合运算:IN、EXISTS、UNION、INTERSECT、EXCEPT
实验 3 换了个玩法,要求在查询里嵌套子查询,并且同一道题用两种方法实现,目的是让你体会「同一种语义有多种写法」以及它们之间的差异。
第一题统计选修「数据库原理」的人数:
SELECT COUNT(DISTINCT sc.Sno) AS Cnt FROM SC sc JOIN Course c ON c.Cno = sc.Cno WHERE c.Cname = N'数据库原理';COUNT(DISTINCT ...)是为了防止一个学生重复选同一门课导致多算。如果确定SC的主码约束生效,COUNT(*)也够用,但显式写DISTINCT意图更明确。
第二题查没有选修「数据库原理」的学生信息,这里有两种写法,差异很大:
-- 写法一:NOT IN,遇到 NULL 会踩坑 SELECT * FROM Student WHERE Sno NOT IN ( SELECT sc.Sno FROM SC sc JOIN Course c ON c.Cno = sc.Cno WHERE c.Cname = N'数据库原理' ); -- 写法二:NOT EXISTS,推荐 SELECT * FROM Student s WHERE NOT EXISTS ( SELECT 1 FROM SC sc JOIN Course c ON c.Cno = sc.Cno WHERE sc.Sno = s.Sno AND c.Cname = N'数据库原理' );如果子查询结果里有NULL,NOT IN整个条件会返回UNKNOWN,结果永远是空集。这里SC.Sno有主码约束不会为NULL,所以两种写法结果一样,但NOT EXISTS不受NULL影响,是更稳的选择。这是个典型的「能跑但不够健壮」的地方,写NOT IN之前先确认子查询的列有没有非空约束。
第三题查其他系中比计算机系学生年龄都小的学生,用ALL比较符:
SELECT * FROM Student WHERE Sdept <> N'计算机系' AND Sbirth > ALL ( SELECT Sbirth FROM Student WHERE Sdept = N'计算机系' AND Sbirth IS NOT NULL );注意年龄小对应出生日期大,所以是Sbirth > ALL(...)。子查询里的Sbirth IS NOT NULL不能去掉:如果子查询返回的集合里混入了NULL,> ALL的比较结果就变成UNKNOWN,整个查询返回空集。这类问题排查起来很隐蔽,因为 SQL 不会报错,只是结果不对。
第四题用UNION和IN两种方法查被两个学生选修过的课程号:
-- UNION 组合 SELECT Cno FROM SC WHERE Sno = '2021001' UNION SELECT Cno FROM SC WHERE Sno = '2021002'; -- IN 条件 SELECT DISTINCT Cno FROM SC WHERE Sno IN ('2021001', '2021002');UNION自带去重,所以第一种写法不需要DISTINCT。如果改用UNION ALL就变成简单拼接,重复的课程号会出现两次,两者语义完全不同,选错了结果看着差不多但数字对不上。第二种写法用IN合并条件,性能通常更好,因为只需扫一次表。
第五题求两个学生同时选修的课程号,用INTERSECT和双EXISTS:
-- INTERSECT SELECT Cno FROM SC WHERE Sno = '2021001' INTERSECT SELECT Cno FROM SC WHERE Sno = '2021002'; -- 双 EXISTS SELECT DISTINCT a.Cno FROM SC a WHERE a.Sno = '2021001' AND EXISTS ( SELECT 1 FROM SC b WHERE b.Sno = '2021002' AND b.Cno = a.Cno );INTERSECT的可读性明显更好,一眼能看出是求交集。但老版本的 SQL Server 不支持这个运算符(2000 之前),得用EXISTS降级。现在主流版本都支持,放心用。
第六题求「被 2021001 选修但没被 2021002 选修」的课程号,用EXCEPT和NOT EXISTS:
-- EXCEPT SELECT Cno FROM SC WHERE Sno = '2021001' EXCEPT SELECT Cno FROM SC WHERE Sno = '2021002'; -- NOT EXISTS SELECT DISTINCT a.Cno FROM SC a WHERE a.Sno = '2021001' AND NOT EXISTS ( SELECT 1 FROM SC b WHERE b.Sno = '2021002' AND b.Cno = a.Cno );EXCEPT是差集运算,注意方向很重要:A EXCEPT B和B EXCEPT A结果不同。这类「有 A 没 B」的需求在数据清洗里非常常见,比如「昨天有订单但今天没发货的商品」,用集合运算写比嵌套子查询直观得多。
5. 数据更新与排查:外码、空值、约束的几处典型翻车
5.1 插入、更新、删除的标准写法
实验 4 是数据的增删改。插入用INSERT INTO ... VALUES,可以一次插多行:
INSERT INTO Student (Sno, Sname, Ssex, Sbirth, Sdept, Smemo) VALUES ('2021001', N'张明', N'男', '2000-03-12', N'计算机系', NULL), ('2021002', N'李芳', N'女', '1999-11-05', N'计算机系', N'转专业'), ('2021003', N'王强', N'男', '2000-07-21', N'数学系', NULL);Ssex这一列显式给了值,所以默认值N'男'不生效。如果想验证默认值,得把这一列从列清单里去掉:
INSERT INTO Student (Sno, Sname, Sbirth, Sdept) VALUES ('2021004', N'赵敏', '2000-05-09', N'外语系'); -- Ssex 自动填 N'男'这两个语句的对比很重要:默认值只在「列被省略」时生效,显式写NULL会真的存NULL进去,而不是替换成默认值。很多人以为DEFAULT是「空值替换器」,实际不是。
更新用UPDATE,一定要带WHERE:
UPDATE SC SET Grade = Grade + 5 WHERE Cno = 'C01' AND Grade IS NOT NULL AND Grade + 5 <= 100;Grade IS NOT NULL是为了跳过还没出成绩的记录,Grade + 5 <= 100是防止加分超过 100 触发 CHECK 约束报错。如果不加这两个条件,UPDATE会直接失败,而且因为是在一个事务里,可能一条都没更新成功。养成习惯:写UPDATE和DELETE之前先用同名WHERE跑一遍SELECT,确认影响范围对得上再执行。
删除用DELETE FROM:
DELETE FROM SC WHERE Sno = '2021001' AND Cno = 'C02';DELETE和TRUNCATE的区别值得知道:TRUNCATE TABLE更快,但它不能带WHERE,而且如果表被外码引用会直接报错。实验里删单条记录用DELETE就够了。
5.2 事务与回滚:改错数据前的后悔药
改数据最怕的就是手滑,UPDATE忘了写WHERE把全表成绩改成 60 分,这种事我见过不止一次。事务是唯一可靠的后悔药:
BEGIN TRAN; UPDATE SC SET Grade = 100 WHERE Sno = '2021001' AND Cno = 'C01'; -- 这里先看「1 行受影响」还是「N 行受影响」 -- 对了就 COMMIT,不对就 ROLLBACK ROLLBACK TRAN;执行完UPDATE后,SSMS 的消息窗口会显示受影响行数,这个数字是判断依据。如果是 1,说明只改了一条,可以COMMIT;如果是 20,说明WHERE条件写宽了,立刻ROLLBACK。这个「先看行数再提交」的动作应该变成肌肉记忆,尤其是在操作没做备份的库时。
有一个细节要注意:默认 SSMS 是自动提交模式,BEGIN TRAN之后如果不写COMMIT或ROLLBACK就关掉窗口,事务会一直挂着,锁住相关行,后面谁查谁等。所以写完ROLLBACK记得把COMMIT那行取消注释,别让半截事务留在会话里。
5.3 五条踩坑记录:现象、原因、解决
坑一:中文姓名存进去变成问号。现象:INSERT执行成功,但SELECT出来姓名显示为??或者乱码。 原因:列定义成VARCHAR而不是NVARCHAR,或者插入时字面量少了N前缀,SQL Server 按非 Unicode 编码转换时把中文丢了。 解决:建表时中文列统一用NVARCHAR/NCHAR,所有中文字面量前面加N,比如N'计算机系'。已经建错的表用ALTER TABLE ... ALTER COLUMN Sname NVARCHAR(20)改类型,然后重灌数据。
坑二:NOT IN子查询返回空集。现象:明明有学生没选「数据库原理」,用NOT IN查出来一条都没有。 原因:子查询结果集里包含NULL,Sno NOT IN (..., NULL)对每一行都返回UNKNOWN。 解决:换成NOT EXISTS,或者在子查询里加WHERE Sno IS NOT NULL。我一般直接用NOT EXISTS,省得每次都要想子查询有没有空值。
坑三:平均成绩算出来是整数。现象:AVG(Grade)的结果永远是整数,82.5 显示成 82。 原因:Grade是SMALLINT,AVG()对整数类型做的是整数除法,小数部分被截断。 解决:AVG(Grade * 1.0)或AVG(CAST(Grade AS DECIMAL(5,2)))。这个坑在计算金额、比率时同样适用,只要是整数列求平均就得转类型。
坑四:删父表数据被外码拦住。现象:DELETE FROM Student WHERE Sno = '2021001'报错,提示与外码约束冲突。 原因:SC表里还有这个学生的选课记录,外码约束阻止删除被引用的行。 解决:先删子表记录DELETE FROM SC WHERE Sno = '2021001',再删主表。或者建外码时加ON DELETE CASCADE,让子记录跟着自动删——但级联删在业务库里要慎用,误删一次能连坐一大片。
坑五:默认值不生效。现象:插入时没给Ssex值,查出来还是NULL。 原因:插入语句里显式写了Ssex列并赋了NULL,默认值只在列被完全省略时才生效。 解决:插入时把该列从列清单里去掉,让数据库自己填。如果业务上不允许NULL,把列定义成NOT NULL加默认值,双保险。
6. 二次开发:把实验报告改成可自动验证的 SQL 练习库
这套东西跑通之后,我最大的感受是:光有 SQL 脚本还不够,答案对不对没法自动确认。每次改完查询都要肉眼比对截图,效率太低。后来我把每个练习题的期望结果存成文件,用脚本跑一遍对比,改造成本很低,收益很明显。
第一步是把题目和期望结果拆开。sql/cases/下每道题一个文件,命名q0201.sql,同目录放q0201.expect,内容是期望结果集的 Python 字面量形式:
sql/cases/ ├─ q0201.sql -- 查询计算机系全体学生 ├─ q0201.expect -- [('2021001','张明',...), ...] ├─ q0202.sql └─ q0202.expect第二步用一个 Python 脚本批量执行并对比:
import pathlib import pyodbc CONN = ( "DRIVER={ODBC Driver 18 for SQL Server};" "SERVER=localhost;DATABASE=StuCou;" "Trusted_Connection=yes;TrustServerCertificate=yes;" ) def run(sql_text: str): with pyodbc.connect(CONN) as conn: cur = conn.cursor() cur.execute(sql_text) return [tuple(row) for row in cur.fetchall()] def check_all(case_dir: pathlib.Path): ok = fail = 0 for sql_file in sorted(case_dir.glob("*.sql")): got = run(sql_file.read_text(encoding="utf-8")) # 练手项目里直接 eval,生产环境请改用 json.loads expect = eval((case_dir / f"{sql_file.stem}.expect").read_text(encoding="utf-8")) if got == expect: ok += 1 print(f"OK {sql_file.stem}") else: fail += 1 print(f"FAIL {sql_file.stem}\n 期望 {expect[:3]}\n 实际 {got[:3]}") print(f"\n通过 {ok} 道,失败 {fail} 道") if __name__ == "__main__": check_all(pathlib.Path("sql/cases"))这段的关键在run()里用with pyodbc.connect(...)保证连接自动关闭,避免脚本跑完一堆挂起连接把库锁住。eval只是为了省事,正式项目里换成json.loads更安全。check_all()遍历目录、逐文件比对,失败时打印前三条,方便定位是哪个字段错位了。
第三步是给练习题加随机化。把02_seed.sql里的固定数据改成 Python 生成的模拟数据,每次随机生成 50 个学生、10 门课、200 条选课记录,期望结果用同一份数据独立算一遍(比如用 pandas 做 groupby 对照 SQL 的GROUP BY)。这样能验证查询在各种数据分布下都对,而不是只在一份刚好凑巧的样本上通过。
改造过程中我自己也踩了个坑:一开始把expect文件用str(list)存,元组里的中文被转成\uXXXX,比对时看着一样实际不等。后来统一改成json.dumps存、json.loads读,问题消失。从那以后我每次建这种「脚本 + 期望值」的回归结构,都强制走一遍json序列化,宁可多写两行也不要用 Python 原生repr当数据格式。希望这套思路能帮到你,把一份静态的实验报告变成能反复跑、能自动验证的练习底子。
本文还有配套的精品资源,点击获取