- 新增 system_settings(公司信息单行)/ business_secondary_allocations(二次 分配明细,比例不强制合计满额,分摊计算待澄清另批)/ approval_orders+ approval_steps(三级审批状态机,开关关闭自动通过留痕) - roles 种子新增营销后勤/财务/领导三职能角色;内置保护改 BUILTIN_ROLE_CODES 白名单(roles 表无 is_builtin 列,不加列) - PARAM_REGISTRY 首例 BOOL:两个审批开关(16→18 项,种子双写) - 双轨同步:V6 导出 SQL + init-dev.sql(表/索引/种子/版本号 79bcc5aa98d4) - 测试同步:EXPECTED_TABLES 四新表、角色种子断言 5 角色、目录断言 18 项 八分组、BOOL 校验边界;迁移 downgrade 修 1553(只保留 drop_table)并补 行级种子反向清理 Co-Authored-By: Claude Code <noreply@anthropic.com>
155 lines
7.0 KiB
Python
155 lines
7.0 KiB
Python
"""DDL 漂移检测。
|
||
|
||
结构定义同时存在于两处:
|
||
- `db/migration/V*.sql` —— 事实来源,生产迁移用
|
||
- `db/init/init-dev.sql` —— 开发环境重置脚本,Docker 首次启动唯一会自动执行的位置
|
||
|
||
两份 DDL 手工同步迟早会漂移,且后果隐蔽:开发库的表结构与迁移脚本不一致,
|
||
新人克隆下来建出的库和生产不一样,等到上线才发现。
|
||
本测试把「靠自觉」变成「可自动拦截」,成本极低。
|
||
|
||
**必须比对全部 V*.sql 的合集**:早期版本只解析 V1,新增 V2 之后,
|
||
两份 DDL 同时缺表仍会被判为「一致」,漂移守卫形同虚设。
|
||
|
||
注:本文件不需要容器,放在 integration 下是因为它校验的是交付产物而非应用代码。
|
||
"""
|
||
|
||
import re
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||
MIGRATION_DIR = PROJECT_ROOT / "db" / "migration"
|
||
INIT_DEV_FILE = PROJECT_ROOT / "db" / "init" / "init-dev.sql"
|
||
ALEMBIC_VERSIONS_DIR = PROJECT_ROOT / "backend" / "alembic" / "versions"
|
||
|
||
_CREATE_TABLE = re.compile(r"CREATE TABLE\s+`?(\w+)`?\s*\((.*?)\n\)", re.DOTALL)
|
||
_COLUMN_LINE = re.compile(r"^\s{4}`?(\w+)`?\s", re.MULTILINE)
|
||
# 这些开头的是约束定义,不是列定义
|
||
_NON_COLUMN_KEYWORDS = {"CONSTRAINT", "PRIMARY", "UNIQUE", "FOREIGN", "KEY", "INDEX"}
|
||
# ALTER TABLE 的列级变更(语义重放用):ADD COLUMN / DROP COLUMN;
|
||
# MODIFY 只改类型不改列名,对列集合无影响,无需处理
|
||
_ALTER_ADD = re.compile(r"ALTER TABLE\s+`?(\w+)`?\s+ADD(?:\s+COLUMN)?\s+`?(\w+)`?", re.IGNORECASE)
|
||
_ALTER_DROP = re.compile(r"ALTER TABLE\s+`?(\w+)`?\s+DROP(?:\s+COLUMN)?\s+`?(\w+)`?", re.IGNORECASE)
|
||
|
||
|
||
def _parse_tables(sql: str) -> dict[str, set[str]]:
|
||
"""从 SQL 文本中提取 {表名: {列名集合}}(仅 CREATE TABLE 的静态解析)。"""
|
||
tables: dict[str, set[str]] = {}
|
||
for table_name, body in _CREATE_TABLE.findall(sql):
|
||
tables[table_name] = {
|
||
column
|
||
for column in _COLUMN_LINE.findall(body)
|
||
if column.upper() not in _NON_COLUMN_KEYWORDS
|
||
}
|
||
return tables
|
||
|
||
|
||
def _migration_final_state(paths: list[Path]) -> dict[str, set[str]]:
|
||
"""按文件序重放迁移脚本,得到迁移链**终态**的 {表名: {列名集合}}。
|
||
|
||
早前实现把全部脚本的 CREATE 列做并集:某表日后被 ALTER DROP COLUMN
|
||
时,并集里旧列名永远存在,init-dev.sql 必须保留死列才能过测 —— 守卫
|
||
会主动背书一份与生产结构漂移的开发库 DDL。按序重放 ADD/DROP 后,
|
||
比对基准才是终态(CHANGE RENAME 列名出现时重放会报差异,属人工核对
|
||
的正确时机:改名同时牵动 init-dev 与 ORM,须人来确认两处同步)。
|
||
"""
|
||
merged: dict[str, set[str]] = {}
|
||
for path in paths:
|
||
sql = path.read_text(encoding="utf-8")
|
||
for table, columns in _parse_tables(sql).items():
|
||
merged.setdefault(table, set()).update(columns)
|
||
for table, column in _ALTER_ADD.findall(sql):
|
||
merged.setdefault(table, set()).add(column)
|
||
for table, column in _ALTER_DROP.findall(sql):
|
||
merged.setdefault(table, set()).discard(column)
|
||
return merged
|
||
|
||
|
||
def _alembic_head() -> str:
|
||
"""从迁移脚本推断当前 head 版本号。
|
||
|
||
纯文本解析而非调用 alembic 运行时 —— 后者需要连数据库,会让本测试
|
||
从「秒级、无需容器」变成「必须起库」。
|
||
head 的定义:没有被任何其他迁移的 down_revision 引用过的那个 revision。
|
||
"""
|
||
revisions: set[str] = set()
|
||
referenced: set[str] = set()
|
||
for path in ALEMBIC_VERSIONS_DIR.glob("*.py"):
|
||
text = path.read_text(encoding="utf-8")
|
||
if found := re.search(r"^revision: str = ['\"](\w+)['\"]", text, re.MULTILINE):
|
||
revisions.add(found.group(1))
|
||
if found := re.search(r"^down_revision:.*?= ['\"](\w+)['\"]", text, re.MULTILINE):
|
||
referenced.add(found.group(1))
|
||
|
||
heads = revisions - referenced
|
||
assert len(heads) == 1, f"迁移链存在分叉或缺失,heads={sorted(heads)}"
|
||
return heads.pop()
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def migration_tables() -> dict[str, set[str]]:
|
||
"""按文件序重放 db/migration/ 下全部 V*.sql,取迁移链终态。"""
|
||
files = sorted(MIGRATION_DIR.glob("V*.sql"))
|
||
assert files, f"未找到迁移脚本:{MIGRATION_DIR}"
|
||
return _migration_final_state(files)
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def init_dev_tables() -> dict[str, set[str]]:
|
||
return _parse_tables(INIT_DEV_FILE.read_text(encoding="utf-8"))
|
||
|
||
|
||
class TestSchemaSync:
|
||
"""两份 DDL 的结构一致性。"""
|
||
|
||
def test_文件存在(self) -> None:
|
||
assert list(MIGRATION_DIR.glob("V*.sql")), f"缺少迁移脚本:{MIGRATION_DIR}"
|
||
assert INIT_DEV_FILE.is_file(), f"缺少开发初始化脚本:{INIT_DEV_FILE}"
|
||
|
||
def test_表集合一致(self, migration_tables: dict, init_dev_tables: dict) -> None:
|
||
assert set(init_dev_tables) == set(migration_tables), (
|
||
"两份 DDL 的表集合不一致 —— 表结构变更时需同时维护 "
|
||
"db/migration/V{n}__*.sql 与 db/init/init-dev.sql"
|
||
)
|
||
|
||
def test_每张表的列集合一致(self, migration_tables: dict, init_dev_tables: dict) -> None:
|
||
mismatched = {
|
||
table: {
|
||
"仅迁移脚本有": sorted(migration_tables[table] - init_dev_tables[table]),
|
||
"仅初始化脚本有": sorted(init_dev_tables[table] - migration_tables[table]),
|
||
}
|
||
for table in set(migration_tables) & set(init_dev_tables)
|
||
if migration_tables[table] != init_dev_tables[table]
|
||
}
|
||
assert not mismatched, f"以下表的列不一致:{mismatched}"
|
||
|
||
|
||
class TestSeedData:
|
||
"""字典数据与版本标记。"""
|
||
|
||
@pytest.mark.parametrize("role_code", ["admin", "user", "marketing_ops", "finance", "leader"])
|
||
def test_角色初始化数据在两处都存在(self, role_code: str) -> None:
|
||
migration_sql = "\n".join(
|
||
path.read_text(encoding="utf-8") for path in sorted(MIGRATION_DIR.glob("V*.sql"))
|
||
)
|
||
init_sql = INIT_DEV_FILE.read_text(encoding="utf-8")
|
||
assert f"'{role_code}'" in migration_sql
|
||
assert f"'{role_code}'" in init_sql
|
||
|
||
def test_开发脚本标记的版本号等于当前head(self) -> None:
|
||
"""版本号必须等于 head,且不能只断言「存在」。
|
||
|
||
落后的后果:开发库重建后执行 `alembic upgrade head`,alembic 认为该迁移
|
||
尚未应用,重复建表并报 1050 Table already exists,只能手工补版本或 down -v。
|
||
"""
|
||
init_sql = INIT_DEV_FILE.read_text(encoding="utf-8")
|
||
found = re.search(
|
||
r"INSERT INTO alembic_version \(version_num\) VALUES \('(\w+)'\)", init_sql
|
||
)
|
||
assert found, "init-dev.sql 未写入 alembic_version"
|
||
assert found.group(1) == _alembic_head(), (
|
||
f"init-dev.sql 标记的版本号 {found.group(1)!r} 落后于当前 head {_alembic_head()!r}"
|
||
)
|