2026-10-09 06:46:36 -04:00
|
|
|
|
"""DDL 漂移检测。
|
|
|
|
|
|
|
|
|
|
|
|
结构定义同时存在于两处:
|
2026-10-09 22:44:05 -04:00
|
|
|
|
- `db/migration/V*.sql` —— 事实来源,生产迁移用
|
2026-10-09 06:46:36 -04:00
|
|
|
|
- `db/init/init-dev.sql` —— 开发环境重置脚本,Docker 首次启动唯一会自动执行的位置
|
|
|
|
|
|
|
|
|
|
|
|
两份 DDL 手工同步迟早会漂移,且后果隐蔽:开发库的表结构与迁移脚本不一致,
|
|
|
|
|
|
新人克隆下来建出的库和生产不一样,等到上线才发现。
|
|
|
|
|
|
本测试把「靠自觉」变成「可自动拦截」,成本极低。
|
|
|
|
|
|
|
2026-10-09 22:44:05 -04:00
|
|
|
|
**必须比对全部 V*.sql 的合集**:早期版本只解析 V1,新增 V2 之后,
|
|
|
|
|
|
两份 DDL 同时缺表仍会被判为「一致」,漂移守卫形同虚设。
|
|
|
|
|
|
|
2026-10-09 06:46:36 -04:00
|
|
|
|
注:本文件不需要容器,放在 integration 下是因为它校验的是交付产物而非应用代码。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import re
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
2026-10-09 22:44:05 -04:00
|
|
|
|
MIGRATION_DIR = PROJECT_ROOT / "db" / "migration"
|
2026-10-09 06:46:36 -04:00
|
|
|
|
INIT_DEV_FILE = PROJECT_ROOT / "db" / "init" / "init-dev.sql"
|
2026-10-09 22:44:05 -04:00
|
|
|
|
ALEMBIC_VERSIONS_DIR = PROJECT_ROOT / "backend" / "alembic" / "versions"
|
2026-10-09 06:46:36 -04:00
|
|
|
|
|
|
|
|
|
|
_CREATE_TABLE = re.compile(r"CREATE TABLE\s+`?(\w+)`?\s*\((.*?)\n\)", re.DOTALL)
|
2026-11-02 22:20:55 -05:00
|
|
|
|
_COLUMN_LINE = re.compile(r"^[ \t]+`?(\w+)`?[ \t]", re.MULTILINE)
|
2026-10-09 06:46:36 -04:00
|
|
|
|
# 这些开头的是约束定义,不是列定义
|
|
|
|
|
|
_NON_COLUMN_KEYWORDS = {"CONSTRAINT", "PRIMARY", "UNIQUE", "FOREIGN", "KEY", "INDEX"}
|
2026-10-14 21:24:34 -04:00
|
|
|
|
# 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)
|
2026-10-09 06:46:36 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_tables(sql: str) -> dict[str, set[str]]:
|
2026-10-14 21:24:34 -04:00
|
|
|
|
"""从 SQL 文本中提取 {表名: {列名集合}}(仅 CREATE TABLE 的静态解析)。"""
|
2026-10-09 06:46:36 -04:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-14 21:24:34 -04:00
|
|
|
|
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):
|
2026-10-18 23:09:05 -04:00
|
|
|
|
# ADD CONSTRAINT ... UNIQUE 的「CONSTRAINT」不是列(与 _parse_tables
|
|
|
|
|
|
# 的约束关键词同一份清单过滤),V8 台账唯一键三列扩展即此形态
|
|
|
|
|
|
if column.upper() not in _NON_COLUMN_KEYWORDS:
|
|
|
|
|
|
merged.setdefault(table, set()).add(column)
|
2026-10-14 21:24:34 -04:00
|
|
|
|
for table, column in _ALTER_DROP.findall(sql):
|
|
|
|
|
|
merged.setdefault(table, set()).discard(column)
|
|
|
|
|
|
return merged
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-09 22:44:05 -04:00
|
|
|
|
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")
|
2026-11-02 22:20:55 -05:00
|
|
|
|
if found := re.search(r"^revision(?:\s*:\s*str)?\s*=\s*['\"](\w+)['\"]", text, re.MULTILINE):
|
2026-10-09 22:44:05 -04:00
|
|
|
|
revisions.add(found.group(1))
|
2026-11-02 22:20:55 -05:00
|
|
|
|
if found := re.search(r"^down_revision(?:\s*:.*?)?\s*=\s*['\"](\w+)['\"]", text, re.MULTILINE):
|
2026-10-09 22:44:05 -04:00
|
|
|
|
referenced.add(found.group(1))
|
|
|
|
|
|
|
|
|
|
|
|
heads = revisions - referenced
|
|
|
|
|
|
assert len(heads) == 1, f"迁移链存在分叉或缺失,heads={sorted(heads)}"
|
|
|
|
|
|
return heads.pop()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-09 06:46:36 -04:00
|
|
|
|
@pytest.fixture(scope="module")
|
|
|
|
|
|
def migration_tables() -> dict[str, set[str]]:
|
2026-10-14 21:24:34 -04:00
|
|
|
|
"""按文件序重放 db/migration/ 下全部 V*.sql,取迁移链终态。"""
|
2026-10-09 22:44:05 -04:00
|
|
|
|
files = sorted(MIGRATION_DIR.glob("V*.sql"))
|
|
|
|
|
|
assert files, f"未找到迁移脚本:{MIGRATION_DIR}"
|
2026-10-14 21:24:34 -04:00
|
|
|
|
return _migration_final_state(files)
|
2026-10-09 06:46:36 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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:
|
2026-10-09 22:44:05 -04:00
|
|
|
|
assert list(MIGRATION_DIR.glob("V*.sql")), f"缺少迁移脚本:{MIGRATION_DIR}"
|
2026-10-09 06:46:36 -04:00
|
|
|
|
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:
|
2026-10-09 22:44:05 -04:00
|
|
|
|
"""字典数据与版本标记。"""
|
2026-10-09 06:46:36 -04:00
|
|
|
|
|
2026-10-15 05:25:23 -04:00
|
|
|
|
@pytest.mark.parametrize("role_code", ["admin", "user", "marketing_ops", "finance", "leader"])
|
2026-10-09 06:46:36 -04:00
|
|
|
|
def test_角色初始化数据在两处都存在(self, role_code: str) -> None:
|
2026-10-09 22:44:05 -04:00
|
|
|
|
migration_sql = "\n".join(
|
|
|
|
|
|
path.read_text(encoding="utf-8") for path in sorted(MIGRATION_DIR.glob("V*.sql"))
|
|
|
|
|
|
)
|
2026-10-09 06:46:36 -04:00
|
|
|
|
init_sql = INIT_DEV_FILE.read_text(encoding="utf-8")
|
|
|
|
|
|
assert f"'{role_code}'" in migration_sql
|
|
|
|
|
|
assert f"'{role_code}'" in init_sql
|
|
|
|
|
|
|
2026-10-09 22:44:05 -04:00
|
|
|
|
def test_开发脚本标记的版本号等于当前head(self) -> None:
|
|
|
|
|
|
"""版本号必须等于 head,且不能只断言「存在」。
|
|
|
|
|
|
|
|
|
|
|
|
落后的后果:开发库重建后执行 `alembic upgrade head`,alembic 认为该迁移
|
|
|
|
|
|
尚未应用,重复建表并报 1050 Table already exists,只能手工补版本或 down -v。
|
|
|
|
|
|
"""
|
2026-10-09 06:46:36 -04:00
|
|
|
|
init_sql = INIT_DEV_FILE.read_text(encoding="utf-8")
|
2026-10-09 22:44:05 -04:00
|
|
|
|
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}"
|
|
|
|
|
|
)
|