"""清理 schema 与 ORM 模型的漂移 Revision ID: 0006_schema_model_drift_cleanup Revises: 0005_prediction_status_and_stats_provenance Create Date: 2026-09-15 背景(见代码审查报告 P1-1): 0005 迁移在 predictions 表留下了 `cutoff_at` 列(删除语句被注释掉), 但 ORM 模型 `Prediction` 中并无该字段 —— 这是一处会长期存在的 schema 漂移, 而 alembic autogenerate 会持续建议 drop 它,造成噪声。 同时 0005 创建的两个索引 `ix_match_stats_available_at` 与 `ix_predictions_cutoff_at` 未在 ORM 声明,autogenerate 会建议删除它们 —— 一旦被误删,数据血缘相关的时间过滤查询会退化为全表扫描。 本迁移做两件事: 1. drop 掉幽灵列 predictions.cutoff_at(数据已由 prediction_cutoff_at 承载, 迁移前先把非空值回填过去,避免丢数据) 2. 显式重建这两个索引(幂等:先 drop if exists 再 create),使 DB 状态与 修正后的 ORM 声明一致 """ from typing import Sequence, Union from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision: str = '0006_schema_model_drift_cleanup' down_revision: Union[str, None] = '0005_prediction_status_and_stats_provenance' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: bind = op.get_bind() inspector = sa.inspect(bind) # --- 1. 幽灵列 cutoff_at: 先把数据回填到 prediction_cutoff_at 再删除 --- pred_cols = {c["name"] for c in inspector.get_columns("predictions")} if "cutoff_at" in pred_cols: if "prediction_cutoff_at" in pred_cols: # 仅回填尚未有值的行,避免覆盖更权威的数据 op.execute( "UPDATE predictions " "SET prediction_cutoff_at = cutoff_at " "WHERE prediction_cutoff_at IS NULL AND cutoff_at IS NOT NULL" ) op.drop_column("predictions", "cutoff_at") # --- 2. 与 ORM 声明对齐的索引(幂等重建) --- stats_idx = {i["name"] for i in inspector.get_indexes("match_stats")} if "ix_match_stats_available_at" not in stats_idx: op.create_index("ix_match_stats_available_at", "match_stats", ["available_at"]) pred_idx = {i["name"] for i in inspector.get_indexes("predictions")} if "ix_predictions_cutoff_at" not in pred_idx: op.create_index( "ix_predictions_cutoff_at", "predictions", ["prediction_cutoff_at"] ) def downgrade() -> None: bind = op.get_bind() inspector = sa.inspect(bind) # 恢复幽灵列(与 0005 的最终状态一致:列存在但无值) pred_cols = {c["name"] for c in inspector.get_columns("predictions")} if "cutoff_at" not in pred_cols: op.add_column( "predictions", sa.Column("cutoff_at", sa.DateTime(timezone=True), nullable=True), ) stats_idx = {i["name"] for i in inspector.get_indexes("match_stats")} if "ix_match_stats_available_at" in stats_idx: op.drop_index("ix_match_stats_available_at", table_name="match_stats") pred_idx = {i["name"] for i in inspector.get_indexes("predictions")} if "ix_predictions_cutoff_at" in pred_idx: op.drop_index("ix_predictions_cutoff_at", table_name="predictions")