# 设计决策

[`docs/architecture.md`](architecture.md) 说明规划器**做什么**。这份文档记录其中三个
最有主张的选择**为什么这么定**：备选方案是什么、为什么被否决、代价在哪。凡是由测试或
脚本守住的结论，都会指明是哪一个，以便读者自行核验，而不必采信文档的说法。

本文是 [design-decisions.md](design-decisions.md) 的中文版，内容一致。

## 一、重命名必须有显式提示

**代码怎么做的。** `diff.mbt` 的 `collect_changes` 对每张源表按
`renamed_table_target(hints, name).unwrap_or(name)` 去目标 schema 里查找；查不到就归为
`DropTable`。列的处理相同。因此在没有提示的情况下，一次重命名必然呈现为「删除 + 新增」。

**备选方案**是按名称相似度推断重命名——编辑距离，或按类型与位置匹配。否决它有两个理由。

第一，**判断错误的代价并不对称**：

| | 结果 | 代价 |
| --- | --- | --- |
| 不推断，而它确实是重命名 | 一个假的 `Destructive` | 写一行提示 |
| 去推断，而它其实不是重命名 | 一个假的 `Safe` | 列被删除，数据丢失，而计划书上写着「安全」 |

这个工具的全部价值在于让数据丢失可见。一个可能掩盖数据丢失的启发式，是在反转它自身的目的。

第二是**确定性**。相似度打分依赖当前作用域内的整个名称集合。今天能匹配上的一对列，可能因为
明天在旁边加了一个无关的列而不再匹配。那将破坏「同样两份 schema 永远产出同样计划」这条
性质，而这条性质正是计划能进入 Pull Request 评审的前提。

**代价**是调用方必须自己写提示。这部分负担由校验承接：`validate_hints` 会拒绝未知的源表或
目标表、未知的源列或目标列、表内非一对一的映射，以及重命名到源 schema 中已存在的名称。
因此一份已经和 schema 脱节的提示文件会**明确报错**，而不是悄悄配错一对。上述每一条错误信息
都有对应用例，见 `planner_cases_test.mbt` 与 `planner_order_test.mbt`。

有一个推论需要讲明白：重命名被分级为 `Review`，而**不是** `Safe`。视图、触发器以及应用中的
裸 SQL 仍可能引用旧名称，规划器看不到它们中的任何一个。显式提示的含义是「规划器没有在猜」，
不等于「这个操作没有代价」。

## 二、退出码 1 与 2 是两种不同的回答

**代码怎么做的。** `cmd/main/main.mbt` 定义了 `EXIT_USAGE = 1` 和 `EXIT_POLICY = 2`。
所有诊断路径都经由 `fail_cli` 走到前者；只有 `reject_policy` 会走到后者。

二者要求的后续动作不同：

| 退出码 | 含义 | 该怎么办 |
| --- | --- | --- |
| `1` | 工具**没能给出回答**。路径错误、JSON 格式损坏、schema 非法、方言不被支持。 | 修正输入后重跑。重试本身没有意义。 |
| `2` | 工具**给出了回答，答案是「不行」**。schema 合法、计划良构，只是包含超出策略上限的步骤。 | 这是需要人介入的决策点：要么改动本身是错的，要么它是有意为之，需要有人用 `--max-risk destructive` 批准。 |

若把两者都归为 `1`，流水线便无从区分「你的 JSON 写坏了」与「你正要删掉生产库的一列」。
分开之后，CI 可以分别应对：收到 `2` 就把 Markdown 计划贴到 PR 上并要求审批；收到 `1`
则按 lint 失败处理，不必多说。

同一思路还决定了两处执行顺序：

- `verify` 在退出 `2` **之前**先写出报告。构建失败时那份产物必须存在——恰恰是失败的时候，
  才最需要有人去读它。
- `plan` 在渲染**之前**先检查策略，因此被拦下的运行**一条 SQL 都不会输出**。这一点没有交给
  代码审查来保证：`scripts/sqlite_e2e.sh` 会在被拦输出中 grep `CREATE TABLE`，一旦找到就
  判定失败。

## 三、SQLite 的变更合并为一次表重建

**约束前提。** SQLite 的 `ALTER TABLE` 支持重命名表、重命名列、新增列和删除列。修改列的
类型或可空性、增删 `UNIQUE` 或 `PRIMARY KEY`、变更外键，则在语法层面完全无法表达。
对这些情形，SQLite 官方给出的做法就是那套十二步流程：建新表、拷数据、删旧表、改名。

**代码怎么做的。** `diff_existing_table` 的 SQLite 分支在以下情况置 `rebuild`：外键有差异、
有列消失、有列的任一字段不同、或新增列是主键或唯一列。随后它发出**单个** `RebuildTable`
变更，而不是若干个细粒度变更。

**为什么合并成一步。** 重建在语义上是原子的。若照 PostgreSQL 路径那样拆成 `DropColumn`、
`AlterColumn`、`AddIndex`，产出的语句要么 SQLite 根本无法执行，要么会对同一张表交错出多次
重建。合并之后计划才是诚实的：**一个步骤对应一次可审计的操作，携带一个风险判定。**

`sqlite_rebuild_sql` 中有几处细节是刻意为之：

- **用 `BEGIN IMMEDIATE` 而非 `BEGIN`。** 写锁在开头就拿到。否则在繁忙的数据库上可能中途
  `SQLITE_BUSY`，而此时新表已经建好、数据已经拷了一部分。
- **用 `foreign_keys=OFF` 包住整段**，这是 SQLite 推荐的顺序——否则删表与改名会触发级联或
  被拒绝。相应的保证在 `COMMIT` 之前恢复，但**不是靠 pragma 本身**：`PRAGMA
  foreign_key_check` 只会*报告*违规，脚本若无视它的输出，照样会提交。因此计划把它的计数
  灌进一个 `CHECK` 约束，把「报告」变成真正的错误。SQL 无法让 `COMMIT` 取决于查询结果，
  所以回滚来自客户端：**迁移必须用 `sqlite3 -bail`，或任何遇错即停的驱动来执行**——事务
  因此保持打开，退出时被回滚。`scripts/sqlite_e2e.sh` 用一个故意注入孤儿行的数据库断言了
  两个方向。
- **`__msp_new_` 是保留前缀。** `validate_schema` 会拒绝任何以它开头的用户表名，因此暂存表
  不可能与真实表冲突。
- **回填必须显式，否则不予规划。** 当可空列变为必填且目标 schema 声明了默认值时，拷贝使用
  `COALESCE(旧列, 默认值)`。若没有默认值，`validate_dialect_transition` 直接拒绝规划，而不是
  生成一条会执行失败、或会悄悄写入 NULL 的 SQL。
- **重建永远不会是 `Safe`。** 删列或转换类型时是 `Destructive`，其余情况是 `Review`——
  因为无论哪种情况，它都重写了整张表。

### 为什么删列不能改用 SQLite 原生的 DROP COLUMN

SQLite 自 3.35 起支持 `ALTER TABLE ... DROP COLUMN`，于是有一个显而易见的优化：限制不适用时
就用它，只在必要时才重建。这个优化在这里**无法被可靠地实现**，原因是结构性的，而不是工作量问题。

SQLite 文档列出了 `DROP COLUMN` 会失败的八种情形。按本项目的 schema IR 是否看得见来划分：

| 情形 | IR 能看见吗？ |
| --- | --- |
| 该列是主键或主键的一部分 | 能 —— `Column::primary_key` |
| 该列带 `UNIQUE` 约束 | 能 —— `Column::unique` |
| 该列被索引 | 能 —— `Table::indexes` |
| 该列被用于外键 | 能 —— `Table::foreign_keys` |
| 该列出现在部分索引的 `WHERE` 谓词中 | **不能** —— 索引谓词未建模 |
| 该列出现在 `CHECK` 约束中 | 能看见 schema 声明的那些——但看不见真实表另外带的 |
| 该列被生成列的表达式使用 | **不能** —— 生成列未建模 |
| 该列出现在触发器或视图中 | 只看得见 schema 声明的，看不见真实库里另外还有的 |

八种里有一半是不可见的，而 CHECK 约束那一行只能算看见一半：IR 知道 schema 声明了哪些约束，
却不知道真实表另外还带着哪些。只依据看得见的部分来放行，就会对一个仍被触发器、视图或未声明的
约束引用的列发出 `DROP COLUMN`，而这条语句在真实数据库上会执行失败。对一个以「在碰数据之前就失败关闭」
为全部契约的规划器来说，**发出可能失败的 SQL，比慢更糟**。

这与整个设计遵循的是同一条信任边界：规划器只拿到两份 schema 描述、从不检查数据库，因此它只能
对这些描述里有的东西做推理。而重建是 SQLite 官方针对一般情形给出的做法，不受这八种情形中的
任何一种影响。

`sqlite_rebuild_test.mbt` 把这一点固定下来：对原生删列而言最理想的情形——一个既非主键、
又不唯一、未被索引、也不参与任何外键的列——仍然只产出一个 `RebuildTable`，且渲染出的 SQL 中
不含 `DROP COLUMN`。同一个文件还断言了对照组：PostgreSQL 对同一个列就是直接 DROP COLUMN。
这样一来，重建读起来是方言约束，而不是某种个人风格。

### 为什么重建要把每个视图和触发器括起来

SQLite 的通用流程会重建索引、触发器和视图，本规划器对**schema 声明过的**那些做同样的事——
但这不是为了整洁。不这么做，重建根本跑不完：

```
Runtime error: error in view vx: no such table: main.x
```

`ALTER TABLE ... RENAME TO` 会重新校验 schema 里的每一个视图和触发器，其中任何一个指向
「此刻不存在的表」都会让该语句失败。`DROP TABLE` 还会顺带删掉该表自己的触发器；而**另一张表
上**、读取被重建表的触发器，同样会挡住重建。所以重建会先对所有已声明的视图和触发器发出
`DROP VIEW` / `DROP TRIGGER`，事后再重建回来——而且是作为普通的计划步骤，不藏在某一步内部：

```
drop view vx · rebuild table x · create view vx
```

schema 没有声明的，仍然会丢，每个重建步骤的 `reason` 都会讲明这点。
`scripts/sqlite_e2e.sh` 会迁移一个同时带视图和触发器的数据库，并检查事后触发器仍会触发、
视图仍可查询；把这个括号去掉，上面那条报错立刻就会回来。

**如何验证。** `scripts/sqlite_e2e.sh` 把生成的 SQL 灌入真实的 `sqlite3` 数据库，断言行仍然
存在、回填已生效、legacy 列已删除、索引已重建、没有残留的暂存表，且 `integrity_check`
返回 ok。

## 三条决策共同的原则

每一处不确定，都往「拒绝」的方向解决，而不是往「继续」的方向。规划器不猜重命名；没有回填
就不规划必填列；策略不满足就不渲染 SQL；退出码把「我不知道」和「我不允许」分开。

代价是这个工具用起来更啰嗦：你得写提示，也得显式批准破坏性变更。换来的是——当它说 `safe`
的时候，这个词是有分量的。
