🦏 第19章 经典案例
📄 一句话概括

需求里的四个名词就是四张表:班级、学生、课程、成绩。 「只属于一个班」是外键,「可以缺考」意味着成绩表里可以没有那一行。 成绩表的主键是「学号 + 课程号」联合——它没有自己的 id,它的身份就是「谁、哪门课」。 灌完数据先验三件事: 行数、foreign_key_check、integrity_check。 这三条比人眼看一遍靠谱得多。

📝 本章和前面几章的关系

这一章是全书收尾,跑在一个新库上:四张表、三条外键、每列都有类型和约束, 和真实项目里的库长得更像。 前面十八章按语法分章,这一章按问题分篇。 三篇是一条线:19.1 把库搭起来,19.2 十道基础题(筛选、排序、聚合), 19.3 九道高级题(窗口、反连接、行转列)。 后面两篇的题都跑在同一个库上,结果可以互相核对。

一、需求长什么样

要做的是一个班级成绩管理系统,最小的功能就是「查成绩」。 需求单这么写: 一、学校里有若干班级,每个班有若干学生。 二、每个学生有姓名、性别、出生日期,且只属于一个班。 三、要开若干门课,每门课有学分。 四、每个学生每门课有一个成绩,可以缺考。 五、要能查:谁考了多少、每门课平均分、班级总分排名。 这五条里藏了两个决定后面全部写法的细节:「只属于一个班」是一对多,所以班级单独一张表; 「可以缺考」意味着成绩不是必填的—— 缺考不是存个 0 分,是根本没有那一行。 这两个字决定了后面所有「缺考怎么查」的写法。

二、从需求推到表结构

先把主键定下来:四张表里三张用自增整数 id 做主键, 只有成绩表例外——它的「身份」是「哪个学生、哪门课」,所以主键是这两列联合起来。 再把关系接上:stu.cls_id 指向 cls.id;score.stu_id 指向 stu.id; score.course_id 指向 course.id。 三条外键,一张图就画完了。 然后是类型:姓名、性别、出生日期、班级名、课程名用 TEXT; 学号、班级号、课程号用 INTEGER;成绩和学分用 REAL。 成绩用 REAL 是有争议的——第 18 章刚说过金额别用浮点。 但成绩的语义就是「近似值」, 而且它不参与「一分不能差」的对账, 所以这里用 REAL 可以接受。 换成金额就必须用定点数或整数存分。 最后补约束:性别只能是男或女(check)、出生日期不能空(not null)、 学分必须大于 0(check)、成绩限在 0 到 100(check)。 这些约束不是装饰—— 19.3 里「缺考怎么查」那道题,就靠「成绩可以为空、行不能凭空造」这个前提才成立。

三、四张表长这样

建表语句在 Demo 代码里,这里只看结构:

表 列 主键 外键
cls id、name id —
stu id、name、gender、birth、cls_id id cls_id → cls.id
course id、name、credit id —
score stu_id、course_id、score (stu_id, course_id) 两条

Demo 里查了一下账本,多出三个 sqlite_autoindex_*——它们是 UNIQUE 约束自动建的索引, 不是我们写的。 第 18 章说过:pragma index_list 里 origin 是 u 的就是这种,删不掉。

四、灌数据:故意造得有戏

六个人分布在一班(2 人)、二班(2 人)、三班(2 人),三门课:语文、数学、英语, 每人三门,共十八条成绩。 这批数据是「故意有戏」的:孙悦三门都高(91/96/93),周杰三门都低(60/58/66), 张伟偏科(88/92/75)。 为什么要故意造得这么分明? 因为后面每道题的结果都要能一眼判断对错。 如果全班分数都挤在 80 到 85 之间,「最高分是谁」和「平均分排序」看起来都一样, SQL 写错了也看不出来。 造测试数据时,「差异明显」比「接近真实」更有用。

五、灌完先验三件事

第一件:行数对不对。 四张表应该是 3、6、3、18。 Demo 里用一句 union all 一次列出来, 比一张一张 count 快。 第二件:外键有没有断的。 pragma foreign_key_check 一条也不返回,就说明没有孤儿行。 这条检查比人眼看一遍靠谱得多,尤其是从别处导数据进来的时候,必跑。 第三件:库文件本身有没有坏。 pragma integrity_check 会扫一遍页结构, 正常返回 ok。 换成 MySQL 是 check table 表名。 这两条命令在「怀疑数据坏了」的时候是第一反应。 注意 pragma foreign_key_check 和 pragma foreign_keys 是两个东西: 前者是一次性体检(查出问题行),后者是开关(决定外键约不约束)。 名字太像,很容易混。

六、用三范式回头验一遍

第一范式:每个格子只放一个值。 成绩单独一张表、一条一行, 没有「三门课塞一格」那种写法。 第二范式:成绩表主键是「学号 + 课程号」,非主键列只有 score 一个, 它换学生变、换课程也变,依赖整个主键。 没有部分依赖。 第三范式:成绩表里没有学生姓名、没有课程名,要显示就 join 过去。 没有传递依赖。 三张主表也都干净: 班级名只在 cls 里存一份,课程名只在 course 里存一份。 改一次,处处生效。 这套结构不是唯一的正确解, 但它满足三范式、每条关系都有外键、每列都有类型和必要的约束—— 做到这三样,后面写查询就不会老是被脏数据绊住。

七、动手 Demo:把库搭起来,再验三遍

Demo 分三段:建四张表、灌一批数据、验三件事。 每段都打印现场,可以照着核。 注意 Demo 里 PRAGMA foreign_keys = ON 是在 executescript 之前执行的, 而且那时还没有开启事务—— 第 18 章说过,这个开关在事务里设是空操作。 顺序错了,后面灌数据时外键就完全不检查。

import sqlite3

DDL = """
CREATE TABLE cls(id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE);
CREATE TABLE stu(id INTEGER PRIMARY KEY, name TEXT NOT NULL,
     gender TEXT NOT NULL CHECK(gender IN ('男','女')),
     birth TEXT NOT NULL, cls_id INT NOT NULL REFERENCES cls(id));
CREATE TABLE course(id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE,
     credit REAL NOT NULL CHECK(credit > 0));
CREATE TABLE score(stu_id INT NOT NULL REFERENCES stu(id),
     course_id INT NOT NULL REFERENCES course(id),
     score REAL CHECK(score BETWEEN 0 AND 100),
     PRIMARY KEY(stu_id, course_id));
"""

c = sqlite3.connect(":memory:")
c.execute("PRAGMA foreign_keys = ON")
c.executescript(DDL)

# STU 是六个学生的元组列表,SCORE 是十八条成绩,见正文表格
c.executemany("INSERT INTO cls VALUES (?,?)",
               [(1, '一班'), (2, '二班'), (3, '三班')])
c.executemany("INSERT INTO course VALUES (?,?,?)",
               [(1, '语文', 3.0), (2, '数学', 4.0), (3, '英语', 3.0)])
c.executemany("INSERT INTO stu VALUES (?,?,?,?,?)", STU)
c.executemany("INSERT INTO score VALUES (?,?,?)", SCORE)
c.commit()

# 灌完先验三件事
for row in c.execute("PRAGMA foreign_key_check"):
    print("外键断了:", row)
print(c.execute("PRAGMA integrity_check").fetchone())

实跑输出:

--------------------------------------------------------------------
第八十四课:案例准备——先把库搭起来
--------------------------------------------------------------------

  需求长什么样
  **要做的是一个班级成绩管理系统**,**最小的功能就是「查成绩」。** **需求单是这么写的:**
  **一、学校里有若干班级,每个班有若干学生。**
  **二、每个学生有姓名、性别、出生日期,且只属于一个班。**
  **三、要开若干门课,每门课有学分。**
  **四、每个学生每门课有一个成绩,可以缺考。**
  **五、要能查:谁考了多少、每门课平均分、班级总分排名。**

  **需求里有四个名词:班级、学生、课程、成绩。** **这就是四张表。** **「只属于一个班」是外键**;
  **「可以缺考」意味着成绩表里可以没有那一行**——
  **不是存个 0 分,是没有这一行。** **这两个字决定了后面很多查询怎么写。**

  从需求推到表结构
  **先把主键定下来**:**四张表都用自增整数 `id` 做主键**,
  **除了成绩表**——**成绩的「身份」是「哪个学生、哪门课」,所以主键是这两列联合。**
  **再把关系接上**:**`stu.cls_id` 指向 `cls.id`**;**`score.stu_id` 指向 `stu.id`**;
  **`score.course_id` 指向 `course.id`**。 **三条外键,一张图就画完了。**
  **然后是类型**:**姓名、性别、出生日期、班级名、课程名用 `TEXT`**;
  **学号、班级号、课程号用 `INTEGER`**;**成绩和学分用 `REAL`**。
  **成绩用 `REAL` 是有争议的**——**第 18 章刚说过金额别用浮点。** **但成绩的语义就是「近似值」**,
  **而且它不参与「一分不能差」的对账**,
  **所以这里用 `REAL` 可以接受。** **换成金额就必须用定点数。**

  四张表长这样
  SQL> CREATE TABLE score(...)
  **建表语句见 Demo 代码。** **这里只看结果——账本里四张表、三个外键:**
  SQL> SELECT type, name FROM sqlite_master
       WHERE type IN ('table','index') AND name NOT LIKE 'sqlite_%' ORDER BY type, name
  type      name
  -----------------
  table     cls
  table     course
  table     score
  table     stu
  (4 行)
  **注意多了三个 `sqlite_autoindex_*`**——**它们是 `UNIQUE` 约束自动建的索引**,
  **不是我们写的。** **第 18 章说过:`pragma index_list` 里 `origin` 是 `u` 的就是这种,删不掉。**

  灌数据:六个人、三个班、三门课、十八条成绩
  **数据是手写进去的**,
  **六个人分布在一班(2 人)、二班(2 人)、三班(2 人)。** **三门课:语文、数学、英语。**
  **每人三门,共十八条成绩。**
  **这批数据是故意「有戏」的**:**孙悦三门都高(91/96/93)**,**周杰三门都低(60/58/66)**,
  **张伟偏科(语文 88、数学 92、英语 75)。** **这样后面每道题的结果都能一眼看出对不对**——
  **如果全班分数都差不多,你根本没法判断 SQL 写对没有。**

  SQL> SELECT * FROM cls
  id     name
  ------------
  1      一班
  2      二班
  3      三班
  (3 行)
  SQL> SELECT * FROM stu
  id     name     gender     birth          cls_id
  -------------------------------------------------
  1      张伟     男         2008-03-12     1
  2      李娜     女         2008-07-25     1
  3      王强     男         2007-11-02     2
  4      赵敏     女         2008-01-19     2
  5      孙悦     女         2008-09-30     3
  6      周杰     男         2007-05-08     3
  (6 行)
  SQL> SELECT * FROM course
  id     name     credit
  -----------------------
  1      语文     3.00
  2      数学     4.00
  3      英语     3.00
  (3 行)
  SQL> SELECT * FROM score
  stu_id     course_id     score
  -----------------------------------
  1          1             88.00
  1          2             92.00
  1          3             75.00
  2          1             95.00
  2          2             88.00
  2          3             91.00
  3          1             72.00
  3          2             65.00
  3          3             80.00
  4          1             85.00
  4          2             79.00
  4          3             88.00
  5          1             91.00
  5          2             96.00
  5          3             93.00
  6          1             60.00
  6          2             58.00
  6          3             66.00
  (18 行)

  灌完先验三件事
  **第一件:行数对不对。** **四张表应该是 3、6、3、18。**
  SQL> SELECT 'cls' AS 表, COUNT(*) AS 行数 FROM cls UNION ALL SELECT 'stu', COUNT(*)
       FROM stu UNION ALL SELECT 'course', COUNT(*) FROM course UNION ALL SELECT 'score',
       COUNT(*) FROM score
  表         行数
  ----------------
  cls        3
  stu        6
  course     3
  score      18
  (4 行)
  **第二件:外键有没有断的。** **`pragma foreign_key_check` 一条也不返回,就说明没有孤儿行。**
  **返回 0 行。** **这条检查比人眼看一遍靠谱得多**——**尤其是从别处导数据进来的时候,必跑。**
  **第三件:库文件本身有没有坏。** **`pragma integrity_check` 会扫一遍页结构:**
  | ok
  **它给的是 `ok`。** **换成 MySQL 是 `check table 表名`。**
  **这两条命令在「怀疑数据坏了」的时候是第一反应。**

  用第 18 章的三条规矩回头验一遍
  **第一范式**:**每个格子只放一个值。** **成绩单独一张表,一条一行**,
  **没有「三门课塞一格」那种写法。**
  **第二范式**:**成绩表的主键是「学号 + 课程号」**,**表里只有 `score` 一列非主键**,
  **它依赖整个主键(换学生变、换课程也变)。** **没有部分依赖。**
  **第三范式**:**成绩表里没有学生姓名、没有课程名**,
  **要显示就 `join` 过去。** **没有传递依赖。**
  **三张主表也都干净**:
  **班级名只在 `cls` 里存一份,课程名只在 `course` 里存一份。** **改一次,处处生效。**

  案例库和课本样例库的关系
  **前面十八章用的是另一套样例表**(`classes`/`students`/`scores`/`goods`/`orders`),
  **那是为了讲语法随手造的。**
  **这一章这套是「按需求设计出来」的**:**四张表、三条外键、每列都有类型和约束**,
  **和真实项目里的库长得更像。** **后面两篇的题目都跑在这套库上。**
  **两套库的差别值得留意**:**样例库为了省事,很多列没加约束**;**案例库把约束都加上了**。
  **这个差别在后面做题时会有影响**——**约束多的库,脏数据进不来,查询结果反而更好解释。**

账本里四张表、三个 sqlite_autoindex_*。 那三个索引是 UNIQUE 约束自动建的, origin 是 u,删不掉。 四张表的行数是 3、6、3、18,一句 union all 全列出来。 pragma foreign_key_check 返回 0 行——没有孤儿行,三条外键全都指得着。 pragma integrity_check 返回 ok,库文件本身没有坏页。 stu 那张表里 birth 全是 YYYY-MM-DD 格式, 这是后面「按出生日期排序」能直接比字符串的前提。 换成 2008/3/12 这种,字典序就跟时间序对不上了。

30 秒实验

  1. 把 score 表的主键改成单独一个自增 id, 「同一学生同一门课只能有一条成绩」这条规则还拦得住吗?
  2. 在 score 表上删掉 stu_id 的外键,再插一条不存在的学号,foreign_key_check 会报什么?
  3. 把 stu.birth 从 TEXT 改成 INTEGER 存时间戳,「按出生日期排序」这句 SQL 要不要改?

常见误区

  1. 把「缺考」存成 0 分。 0 分是真考了得 0 分,缺考是没考,平均分、及格率的算法完全不同。
  2. 成绩表用自增 id 做单列主键。 那样「同一学生同一门课」就能插两条,联合主键才拦得住。
  3. 灌完数据只看行数就收工。 行数对不代表数据干净, foreign_key_check 和 integrity_check 各跑一遍才踏实。
自测三问(点开对答案) 1. 为什么成绩表用联合主键而不是自增 id? —— 因为一条成绩的「身份」就是「哪个学生、哪门课」, 联合主键同时保证唯一性——同一学生同一门课只能有一条记录。用自增 id 的话这条规则就没人管了。 2. 「可以缺考」这个需求影响了哪些设计? —— 成绩表里允许没有那一行(不是存 0),`score` 列可以为空, 而且后面查「谁缺考」必须用反连接或 `not exists`,不能用 `score = 0` 或 `score < 60` 去凑。 3. `pragma foreign_key_check` 和 `pragma foreign_keys` 有什么区别? —— 前者是一次性体检,返回所有违反外键的行; 后者是开关,决定外键约束是否生效,默认关闭、且必须在无事务时设置。

库搭好了,下一篇开始做题:19.2 基础问题——十道题过一遍。

相关笔记