""" ORM 声明式写法的 2.0 迁移不变量。 基类由 1.x 的 @as_declarative() 迁移到 2.0 的 DeclarativeBase,列声明由 Column() 迁移到 mapped_column()。两者产出的表定义必须完全等价——迁移只改写法,不改 schema,否则会与既有数据库和 alembic 迁移链产生偏差。 仓内模型已全部迁移到 Mapped[] 注解,__allow_unmapped__ 已随之移除;这些测试守住 「标志不在、且仓内没有任何需要它的写法」这一组不变量,三条断言互为前提:标志一旦 被加回来,下面两条 AST 守卫就会失去意义(legacy 写法将不再报错,只会静默通过)。 """ import ast import re from pathlib import Path import pytest from sqlalchemy.orm import DeclarativeBase from app.db.base import Base from app.db.models import load_all_models load_all_models() from app.db.base import get_id_column PROJECT_ROOT = Path(__file__).resolve().parents[1] DB_PACKAGE = PROJECT_ROOT / "app" / "db" # 匹配 Mapped[...]、orm.Mapped[...] 等带限定前缀的写法;不带下标的裸 Mapped 不算 MAPPED_ANNOTATION = re.compile(r"^(?:[\w.]+\.)?Mapped\[") SQLALCHEMY = "sqlalchemy" def _sqlalchemy_column_names(tree: ast.Module): """ 解析该模块的 import,产出「在本文件中指向 sqlalchemy.Column 的全部名字」。 照字面量 "Column" 硬匹配会两头出错:一头漏掉 ``from sqlalchemy import Column as Col`` 这类别名,另一头误伤同名的无关符号(rich.table.Column 就叫这个名字,而 rich 是本仓 依赖)。按 import 绑定判定,两个方向都准,代价只是多解析一遍 import。 注意 ``sqlalchemy.Column`` 与 ``sqlalchemy.orm.mapped_column`` 是两个东西,这里 只认前者:mapped_column 名字里虽然也有 column,但它是 2.0 的正确写法,不该被拦。 """ names = set() for node in ast.walk(tree): if isinstance(node, ast.ImportFrom): # from sqlalchemy import Column [as Col] / from sqlalchemy.sql import Column module = node.module or "" if module == SQLALCHEMY or module.startswith(f"{SQLALCHEMY}."): names.update(alias.asname or alias.name for alias in node.names if alias.name == "Column") elif isinstance(node, ast.Import): for alias in node.names: if alias.name != SQLALCHEMY and not alias.name.startswith(f"{SQLALCHEMY}."): continue # import sqlalchemy as sa 绑定 sa;import sqlalchemy[.orm] 绑定顶层包名 bound = alias.asname or alias.name.split(".")[0] names.add(f"{bound}.Column") return names def _class_level_column_assignments(py_file: Path): """ 产出该文件中全部「类级 Column(...) 赋值」,形如 (类名, 属性名, 行号)。 取 ClassDef 直接子语句中的 Assign 与 AnnAssign:前者是 1.x 的典型写法 ``foo = Column(String)``(完全没有注解,因此上面那条注解守卫对它无感);后者兜住 ``foo: Mapped[str] = Column(String)`` 这种注解已迁完、构造还留在 1.x 的半吊子状态。 赋值右侧用 ast.walk 递归找 Call,包一层(如 ``deferred(Column(...))``)也跑不掉。 """ tree = ast.parse(py_file.read_text(encoding="utf-8")) column_names = _sqlalchemy_column_names(tree) if not column_names: return for node in ast.walk(tree): if not isinstance(node, ast.ClassDef): continue for stmt in node.body: if not isinstance(stmt, (ast.Assign, ast.AnnAssign)) or stmt.value is None: continue if not any(isinstance(sub, ast.Call) and ast.unparse(sub.func) in column_names for sub in ast.walk(stmt.value)): continue targets = stmt.targets if isinstance(stmt, ast.Assign) else [stmt.target] yield (node.name, ", ".join(ast.unparse(t) for t in targets), stmt.lineno) def _class_level_annotations(py_file: Path): """ 产出该文件中全部类级注解,形如 (类名, 属性名, 注解源码, 行号)。 只取 ClassDef 直接子语句中的 AnnAssign:函数体内的局部注解、模块级注解都不算 类级注解;``if TYPE_CHECKING:`` 块里的注解运行期根本不存在,声明式系统也看不到, 同样不在此列。独立的 dataclass 是 worker、DTO 等运行时数据结构,不参与 SQLAlchemy 声明式映射,也不属于本守卫的范围。 """ tree = ast.parse(py_file.read_text(encoding="utf-8")) for node in ast.walk(tree): if not isinstance(node, ast.ClassDef): continue if any( ast.unparse(decorator).split("(", maxsplit=1)[0].split(".")[-1] == "dataclass" for decorator in node.decorator_list ): continue for stmt in node.body: if isinstance(stmt, ast.AnnAssign): yield (node.name, ast.unparse(stmt.target), ast.unparse(stmt.annotation), stmt.lineno) def test_base_uses_declarative_base(): """ 基类必须是 2.0 的 DeclarativeBase,而不是 1.x 的 as_declarative 产物。 """ assert issubclass(Base, DeclarativeBase) def test_allow_unmapped_is_not_set(): """ __allow_unmapped__ 必须保持缺席。 它此前唯一的存在理由是「仓外插件可能继承本 Base 自定义 legacy 注解模型」;插件 生态确定迭代后这条理由已不成立,标志随之移除。这里断言它不存在而不是删掉用例: 这个标志的危害在于**静默**——加回来之后,2.0 声明式系统不再拒绝未包裹在 Mapped[] 中的类级注解,本文件另外两条 AST 守卫拦下的 legacy 写法就会一路通过 映射,直到运行期以「列不存在」的形式暴露。没有这条断言,谁把它加回来都没人知道。 不用 getattr(..., False) is False:那样无法区分「没有这个属性」和 「显式设成了 False」,而后者同样是把这个开关重新引入了代码。 """ assert not hasattr(Base, "__allow_unmapped__") def test_no_unmapped_class_level_annotations_in_db_package(): """ app/db 内的 ORM 声明不存在非 Mapped[] 的类级注解——这是移除 __allow_unmapped__ 的前提。 上一条用例断言标志不在,本条断言仓内确实不需要它。两者缺一不可:只断言标志不在, 则某天有人补进一条 legacy 注解、发现 import 就炸、顺手把标志加回来,上一条用例 会跟着被改绿;只断言注解形状,则标志被悄悄加回来时没有任何用例会响。 这条用例还把一句会腐烂的注释钉成了可执行断言:base.py 的 docstring 上一版写着 「现有 22 个模型仍是 legacy Column() 写法」,在 329 列全部迁移完之后仍原样留了 很久,主动误导读者。 扫全部 ORM 类而非只扫 Base 子类:判定 Base 子类要么靠运行期 Base.__subclasses__(), 要么靠 AST 解析基类名。前者会漏掉「新增了模型文件但还没接进 app/db/models/__init__.py」 的情况——恰恰是最可能带进 legacy 注解的场景;后者一遇 mixin 或跨文件继承就不准。 纯静态扫描仍保留这个盲区保护;独立 dataclass 已在扫描函数中排除,其他非 ORM 类 若声明类级注解仍会被报告,避免通过装饰器名称伪装模型。 变红时怎么办(二选一,别直接把用例删了): 1. 常见情况——新模型忘了用 2.0 写法,把它改成 mapped_column() + Mapped[] 即可; 2. 若确实需要一条非映射的类级属性,用 ClassVar 显式声明——2.0 的声明式系统会 跳过 ClassVar,这条路不需要 __allow_unmapped__。本守卫比 SQLAlchemy 更严, 当下 app/db 里没有这种属性,所以不预留白名单;真要引入时请显式放宽本守卫 (在 MAPPED_ANNOTATION 之外放行 ClassVar),而不是把那个标志加回来。 """ offenders = [ f"{py_file.relative_to(PROJECT_ROOT)}:{lineno} {cls_name}.{attr} -> {annotation}" for py_file in sorted(DB_PACKAGE.rglob("*.py")) for cls_name, attr, annotation, lineno in _class_level_annotations(py_file) if not MAPPED_ANNOTATION.match(annotation) ] assert not offenders, ( "app/db 内出现了非 Mapped[] 的类级注解,而 __allow_unmapped__ 已移除," "声明式系统会直接拒绝它们:\n" + "\n".join(f" {item}" for item in offenders) + "\n请改用 mapped_column() + Mapped[];若这条注解确实不该被映射," "用 ClassVar 声明并显式放宽本守卫,不要把 __allow_unmapped__ 加回来。" ) def test_no_legacy_column_assignments_in_db_package(): """ app/db 内不存在 1.x 的 Column() 列声明——一律 mapped_column() + Mapped[]。 这条补的是上一条注解守卫的缝:它只校验「已有的注解是不是 Mapped[]」,对**完全没有 类级注解**的裸 ``foo = Column(String)`` 无感。上游新增的 app/db/models/agenttaskrun.py 整整 254 行、零注解、全是 Column(),就是这么从守卫底下溜过去的——直到 pyright app/db 从 0 退回 3 errors 才被发现。这一批工作的卖点是「app/db 全量迁到 SQLAlchemy 2.0」, 守卫拦不住 1.x 写法回流,卖点就只是一次性的。 与上一条分开报而不是合并:两者失败原因不同(注解形状 vs 列构造 API),修法也不同, 合成一条只会让 offender 列表里混着两种毛病、报错文案被迫说得含糊。 Column 按 import 绑定识别而非按名字字面量,别名(``from sqlalchemy import Column as Col``) 与限定写法(``sa.Column``)都算数,详见 _sqlalchemy_column_names。 变红时怎么办:把 ``foo = Column(String)`` 改成 ``foo: Mapped[str] = mapped_column(String)``(Optional 列写 Mapped[Optional[str]]), 主键用 get_id_column()。注意 __allow_unmapped__ 已经移除,没有任何东西替你兜底: 仓内已全量 2.0,见上一条用例。 """ offenders = [ f"{py_file.relative_to(PROJECT_ROOT)}:{lineno} {cls_name}.{attr}" for py_file in sorted(DB_PACKAGE.rglob("*.py")) for cls_name, attr, lineno in _class_level_column_assignments(py_file) ] assert not offenders, ( "app/db 内出现了 1.x 的 Column() 列声明,2.0 迁移被回流破坏:\n" + "\n".join(f" {item}" for item in offenders) + "\n请改用 mapped_column() + Mapped[] 注解(主键用 get_id_column())。" "仓内已全量迁至 2.0,__allow_unmapped__ 已移除,没有兜底可依赖。" ) def test_id_column_factory_returns_mapped_column(): """ 主键工厂必须产出 mapped_column,否则主键仍是 legacy 构造。 """ column = get_id_column() # mapped_column() 返回 MappedColumn,Column() 返回 Column assert type(column).__name__ == "MappedColumn" def test_all_models_registered_and_mapped(): """ 全部模型都应完成映射并拥有主键——迁移过程中最容易出现的失败是某个模型 因导入缺失而未注册,此时它不会报错,只是悄悄从 metadata 里消失。 """ tables = Base.metadata.tables assert len(tables) >= 20, f"注册的表过少({len(tables)}),可能有模型未完成导入" without_pk = [name for name, table in tables.items() if not table.primary_key.columns] assert not without_pk, f"以下表缺少主键: {without_pk}" @pytest.mark.parametrize("table_name", ["transferhistory", "downloadhistory", "subscribe"]) def test_core_tables_keep_column_definitions(table_name): """ 核心业务表的列定义必须完整。抽查而非全量比对:全量等价性已在迁移时以 schema 快照逐列核对过,这里只防止后续改动悄悄丢列。 """ table = Base.metadata.tables[table_name] assert len(table.columns) > 5 assert table.primary_key.columns, f"{table_name} 缺少主键" # 主键应为自增整型 id primary_key = list(table.primary_key.columns)[0] assert primary_key.name == "id" assert "INTEGER" in str(primary_key.type).upper() def test_no_legacy_declarative_api_in_base(): """ base 模块不应再引用 1.x 的 as_declarative,避免两种风格并存。 """ source = Path("app/db/base.py").read_text(encoding="utf-8") assert "as_declarative" not in source