fix(database): harden fresh migration chain compatibility (#6278)

This commit is contained in:
InfinityPacer
2026-08-12 21:29:08 +08:00
committed by GitHub
parent 4fac241658
commit 46c2a856c4
5 changed files with 1234 additions and 67 deletions

View File

@@ -145,6 +145,13 @@ DOWNGRADE_RESTORE_INDEXES = {
def _load_schema_state(inspector: sa.Inspector):
tables = set(inspector.get_table_names())
table_columns = {
table_name: {
column["name"]
for column in inspector.get_columns(table_name)
}
for table_name in tables
}
table_indexes = {
table_name: {
index["name"]: {
@@ -155,7 +162,7 @@ def _load_schema_state(inspector: sa.Inspector):
}
for table_name in tables
}
return tables, table_indexes
return tables, table_columns, table_indexes
def _drop_index(
@@ -215,10 +222,13 @@ def _create_index(
index_name: str,
columns: list[str],
tables: set[str],
table_columns: dict[str, set[str]],
table_indexes: dict[str, dict[str, dict[str, object]]],
) -> None:
if table_name not in tables:
return
if not set(columns).issubset(table_columns[table_name]):
return
if index_name in table_indexes[table_name]:
return
if _has_index_signature(table_name, columns, tables, table_indexes, unique=False):
@@ -231,8 +241,9 @@ def _create_index(
def upgrade() -> None:
"""以字段签名幂等替换 2.2.4 高频查询索引。"""
inspector = sa.inspect(op.get_bind())
tables, table_indexes = _load_schema_state(inspector)
tables, table_columns, table_indexes = _load_schema_state(inspector)
for table_name, index_specs in REDUNDANT_ID_INDEXES.items():
for index_name, columns in index_specs:
@@ -258,12 +269,20 @@ def upgrade() -> None:
for table_name, index_specs in CREATE_INDEXES.items():
for index_name, columns in index_specs:
_create_index(table_name, index_name, columns, tables, table_indexes)
_create_index(
table_name,
index_name,
columns,
tables,
table_columns,
table_indexes,
)
def downgrade() -> None:
"""移除组合索引并恢复适用于当前表结构的旧索引。"""
inspector = sa.inspect(op.get_bind())
tables, table_indexes = _load_schema_state(inspector)
tables, table_columns, table_indexes = _load_schema_state(inspector)
for table_name, index_specs in CREATE_INDEXES.items():
for index_name, _ in index_specs:
@@ -271,8 +290,22 @@ def downgrade() -> None:
for table_name, index_specs in DOWNGRADE_RESTORE_INDEXES.items():
for index_name, columns in index_specs:
_create_index(table_name, index_name, columns, tables, table_indexes)
_create_index(
table_name,
index_name,
columns,
tables,
table_columns,
table_indexes,
)
for table_name, index_specs in REDUNDANT_ID_INDEXES.items():
for index_name, columns in index_specs:
_create_index(table_name, index_name, columns, tables, table_indexes)
_create_index(
table_name,
index_name,
columns,
tables,
table_columns,
table_indexes,
)

View File

@@ -36,55 +36,87 @@ def _has_column(
)
def _has_index(
inspector: sa.Inspector,
table_name: str,
index_name: str,
) -> bool:
"""检查数据表是否已存在指定索引。"""
if table_name not in inspector.get_table_names():
return False
return any(
index["name"] == index_name
for index in inspector.get_indexes(table_name)
)
def _column_names(inspector: sa.Inspector, table_name: str) -> set[str]:
"""读取数据表当前全部字段名。"""
if table_name not in inspector.get_table_names():
return set()
return {
column["name"]
for column in inspector.get_columns(table_name)
}
def _ensure_column_and_index(column_name: str) -> None:
"""独立补齐整理历史的规范身份字段及其索引。"""
table_name = "transferhistory"
index_name = f"ix_{table_name}_{column_name}"
inspector = sa.inspect(op.get_bind())
if not _has_column(inspector, table_name, column_name):
op.add_column(
table_name,
sa.Column(column_name, sa.String(), nullable=True),
)
inspector = sa.inspect(op.get_bind())
if not _has_index(inspector, table_name, index_name):
op.create_index(index_name, table_name, [column_name])
def upgrade() -> None:
"""升级整理历史数据源字段。"""
inspector = sa.inspect(op.get_bind())
if not _has_column(inspector, "transferhistory", "media_source"):
op.add_column(
"transferhistory",
sa.Column("media_source", sa.String(), nullable=True),
)
op.create_index(
"ix_transferhistory_media_source",
"transferhistory",
["media_source"],
)
_ensure_column_and_index("media_source")
_ensure_column_and_index("media_id")
inspector = sa.inspect(op.get_bind())
if not _has_column(inspector, "transferhistory", "media_id"):
op.add_column(
"transferhistory",
sa.Column("media_id", sa.String(), nullable=True),
)
op.create_index(
"ix_transferhistory_media_id",
"transferhistory",
["media_id"],
)
transfer_history = sa.table(
"transferhistory",
sa.column("tmdbid", sa.Integer()),
sa.column("doubanid", sa.String()),
columns = _column_names(sa.inspect(op.get_bind()), "transferhistory")
table_columns = [
sa.column("media_source", sa.String()),
sa.column("media_id", sa.String()),
)
]
if "tmdbid" in columns:
table_columns.append(sa.column("tmdbid", sa.Integer()))
if "doubanid" in columns:
table_columns.append(sa.column("doubanid", sa.String()))
transfer_history = sa.table("transferhistory", *table_columns)
connection = op.get_bind()
connection.execute(
transfer_history.update()
.where(transfer_history.c.tmdbid.is_not(None))
.where(transfer_history.c.media_id.is_(None))
.values(
media_source="themoviedb",
media_id=sa.cast(transfer_history.c.tmdbid, sa.String()),
if "tmdbid" in columns:
connection.execute(
transfer_history.update()
.where(transfer_history.c.tmdbid.is_not(None))
.where(transfer_history.c.media_id.is_(None))
.values(
media_source="themoviedb",
media_id=sa.cast(transfer_history.c.tmdbid, sa.String()),
)
)
)
connection.execute(
if "doubanid" not in columns:
return
douban_update = (
transfer_history.update()
.where(transfer_history.c.tmdbid.is_(None))
.where(transfer_history.c.doubanid.is_not(None))
.where(transfer_history.c.media_id.is_(None))
.values(
)
if "tmdbid" in columns:
douban_update = douban_update.where(
transfer_history.c.tmdbid.is_(None)
)
connection.execute(
douban_update.values(
media_source="douban",
media_id=transfer_history.c.doubanid,
)
@@ -93,15 +125,12 @@ def upgrade() -> None:
def downgrade() -> None:
"""回滚整理历史数据源字段。"""
inspector = sa.inspect(op.get_bind())
if _has_column(inspector, "transferhistory", "media_id"):
op.drop_index("ix_transferhistory_media_id", table_name="transferhistory")
op.drop_column("transferhistory", "media_id")
for column_name in ("media_id", "media_source"):
index_name = f"ix_transferhistory_{column_name}"
inspector = sa.inspect(op.get_bind())
if _has_index(inspector, "transferhistory", index_name):
op.drop_index(index_name, table_name="transferhistory")
inspector = sa.inspect(op.get_bind())
if _has_column(inspector, "transferhistory", "media_source"):
op.drop_index(
"ix_transferhistory_media_source",
table_name="transferhistory",
)
op.drop_column("transferhistory", "media_source")
inspector = sa.inspect(op.get_bind())
if _has_column(inspector, "transferhistory", column_name):
op.drop_column("transferhistory", column_name)

View File

@@ -46,6 +46,17 @@ def _has_index(
)
def _column_names(table_name: str) -> set[str]:
"""读取数据表当前全部字段名。"""
inspector = sa.inspect(op.get_bind())
if table_name not in inspector.get_table_names():
return set()
return {
column["name"]
for column in inspector.get_columns(table_name)
}
def _add_columns(table_name: str, columns: Iterable[sa.Column]) -> None:
"""为指定表补充尚不存在的字段。"""
for column in columns:
@@ -61,22 +72,29 @@ def _create_index(table_name: str, index_name: str, columns: list[str]) -> None:
op.create_index(index_name, table_name, columns)
def _backfill_media_identity(table_name: str, has_mediaid: bool = False) -> None:
"""使用兼容 ID 幂等回填统一媒体身份。"""
def _backfill_media_identity(table_name: str) -> None:
"""使用表中实际存在的兼容 ID 幂等回填统一媒体身份。"""
existing_columns = _column_names(table_name)
columns = [
sa.column("tmdbid", sa.Integer()),
sa.column("doubanid", sa.String()),
sa.column("bangumiid", sa.Integer()),
sa.column("anilistid", sa.Integer()),
sa.column("media_source", sa.String()),
sa.column("media_id", sa.String()),
]
if has_mediaid:
columns.append(sa.column("mediaid", sa.String()))
identity_columns = {
"mediaid": sa.String,
"tmdbid": sa.Integer,
"doubanid": sa.String,
"bangumiid": sa.Integer,
"anilistid": sa.Integer,
}
columns.extend(
sa.column(column_name, column_type())
for column_name, column_type in identity_columns.items()
if column_name in existing_columns
)
table = sa.table(table_name, *columns)
connection = op.get_bind()
if has_mediaid:
if "mediaid" in existing_columns:
for prefix, source in (
("tmdb", "themoviedb"),
("themoviedb", "themoviedb"),
@@ -100,6 +118,8 @@ def _backfill_media_identity(table_name: str, has_mediaid: bool = False) -> None
("bangumi", "bangumiid"),
("anilist", "anilistid"),
):
if field not in existing_columns:
continue
identity_column = table.c[field]
connection.execute(
table.update()
@@ -162,8 +182,8 @@ def upgrade() -> None:
["type", "media_source", "media_id", "site"],
)
_backfill_media_identity("subscribe", has_mediaid=True)
_backfill_media_identity("subscribehistory", has_mediaid=True)
_backfill_media_identity("subscribe")
_backfill_media_identity("subscribehistory")
_backfill_media_identity("downloadhistory")
_backfill_media_identity("transferhistory")
_backfill_media_identity("downloadfailure")

View File

@@ -0,0 +1,402 @@
import importlib
import os
from pathlib import Path
import subprocess
import sys
import uuid
import psycopg2
from psycopg2 import sql
import pytest
import sqlalchemy as sa
from alembic.migration import MigrationContext
from alembic.operations import Operations
MIGRATION_MODULE = "database.versions.93f8cb6a4d1e_2_2_4"
MEDIA_TABLES = (
"subscribe",
"subscribehistory",
"downloadhistory",
"transferhistory",
"downloadfailure",
"mediaserveritem",
)
LEGACY_IDENTITY_COLUMNS = {
"tmdbid",
"imdbid",
"tvdbid",
"doubanid",
"bangumiid",
"anilistid",
"mediaid",
}
IDENTITY_INDEX_SIGNATURES = {
"subscribe": {
"ix_subscribe_media_identity": (("media_source", "media_id"), False),
},
"subscribehistory": {
"ix_subscribehistory_media_identity": (
("media_source", "media_id"), False,
),
},
"downloadhistory": {
"ix_downloadhistory_media_identity": (
("media_source", "media_id"), False,
),
},
"transferhistory": {
"ix_transferhistory_media_identity": (
("media_source", "media_id"), False,
),
},
"downloadfailure": {
"ix_downloadfailure_media_identity_site": (
("type", "media_source", "media_id", "site"), False,
),
},
"mediaserveritem": {
"ix_mediaserveritem_media_identity_type": (
("media_source", "media_id", "item_type"), False,
),
},
}
CURRENT_SCHEMA_CHAIN_SCRIPT = """
from app.testing.bootstrap import ensure_sites_stub
# Alembic 会导入引用业务链的旧 revision全新 CI 环境没有动态下发的 sites 模块。
ensure_sites_stub()
from alembic.config import Config
from alembic.script import ScriptDirectory
from sqlalchemy import inspect, text
from sqlalchemy.exc import IntegrityError
from app.core.config import settings
from app.db import Engine
from app.db.init import init_db, update_db
media_tables = {media_tables!r}
legacy_identity_columns = {legacy_identity_columns!r}
identity_index_signatures = {identity_index_signatures!r}
config = Config()
config.set_main_option("script_location", str(settings.ROOT_PATH / "database"))
heads = ScriptDirectory.from_config(config).get_heads()
assert len(heads) == 1, heads
init_db()
update_db()
update_db()
with Engine.connect() as connection:
version = connection.execute(
text("SELECT version_num FROM alembic_version")
).scalar_one()
inspector = inspect(connection)
assert version == heads[0], (version, heads)
for table_name in media_tables:
columns = {{
column["name"]
for column in inspector.get_columns(table_name)
}}
indexes = {{
index["name"]: (
tuple(index.get("column_names") or ()),
bool(index.get("unique")),
)
for index in inspector.get_indexes(table_name)
}}
constraints = {{
constraint["name"]: constraint.get("sqltext") or ""
for constraint in inspector.get_check_constraints(table_name)
}}
assert {{"media_source", "media_id"}}.issubset(columns), (
table_name,
columns,
)
assert legacy_identity_columns.isdisjoint(columns), (
table_name,
columns,
)
for index_name, signature in identity_index_signatures[table_name].items():
assert indexes.get(index_name) == signature, (
table_name,
index_name,
indexes,
)
constraint_name = f"ck_{{table_name}}_media_identity"
assert constraint_name in constraints, (
table_name,
constraints,
)
normalized_sql = "".join(
constraints[constraint_name].lower().replace('"', '').split()
)
for text_cast in ("::text[]", "::text", "::charactervarying"):
normalized_sql = normalized_sql.replace(text_cast, "")
for fragment in (
"media_sourceisnull",
"media_idisnull",
"media_sourceisnotnull",
"media_idisnotnull",
"'themoviedb'",
"'anilist'",
):
assert fragment in normalized_sql, (
table_name,
constraints[constraint_name],
)
assert any(
trim_form in normalized_sql
for trim_form in (
"trim(media_id)",
"trim(bothfrommedia_id)",
)
), (table_name, constraints[constraint_name])
assert "<>''" in normalized_sql, (
table_name,
constraints[constraint_name],
)
assert "<>'0'" in normalized_sql, (
table_name,
constraints[constraint_name],
)
constraint_name = "ck_mediaserveritem_media_identity"
try:
with connection.begin_nested():
connection.execute(
text(
"INSERT INTO mediaserveritem (media_source, media_id) "
"VALUES (:media_source, :media_id)"
),
{{"media_source": "invalid_source", "media_id": "1"}},
)
except IntegrityError as error:
assert constraint_name in str(error.orig), str(error.orig)
else:
raise AssertionError("非法媒体身份未被具名检查约束拒绝")
""".format(
media_tables=MEDIA_TABLES,
legacy_identity_columns=LEGACY_IDENTITY_COLUMNS,
identity_index_signatures=IDENTITY_INDEX_SIGNATURES,
)
def _index_signatures(
connection,
table_name: str,
) -> dict[str, tuple[tuple[str, ...], bool]]:
"""返回索引名称到字段顺序及唯一性的映射。"""
return {
index["name"]: (
tuple(index.get("column_names") or ()),
bool(index.get("unique")),
)
for index in sa.inspect(connection).get_indexes(table_name)
}
def _bind_migration(monkeypatch, connection):
"""把历史 revision 绑定到当前 disposable connection。"""
migration = importlib.import_module(MIGRATION_MODULE)
context = MigrationContext.configure(connection)
monkeypatch.setattr(migration, "op", Operations(context))
return migration
def _run_current_schema_chain(
repository: Path,
environment: dict[str, str],
) -> None:
"""在隔离数据库中执行当前建表、完整升级及最终结构断言。"""
completed = subprocess.run(
[sys.executable, "-c", CURRENT_SCHEMA_CHAIN_SCRIPT],
cwd=repository,
env=environment,
capture_output=True,
text=True,
timeout=180,
check=False,
)
assert completed.returncode == 0, (
f"stdout:\n{completed.stdout}\n"
f"stderr:\n{completed.stderr}"
)
def test_index_migration_preserves_legacy_media_server_semantics(
monkeypatch,
) -> None:
"""旧字段存在时应保持 2.2.4 的索引替换与回滚语义。"""
engine = sa.create_engine("sqlite://")
metadata = sa.MetaData()
media_server = sa.Table(
"mediaserveritem",
metadata,
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("tmdbid", sa.Integer()),
sa.Column("item_type", sa.String()),
)
sa.Index("ix_mediaserveritem_id", media_server.c.id)
sa.Index("ix_mediaserveritem_tmdbid", media_server.c.tmdbid)
with engine.begin() as connection:
metadata.create_all(connection)
migration = _bind_migration(monkeypatch, connection)
migration.upgrade()
migration.upgrade()
upgraded = _index_signatures(connection, "mediaserveritem")
assert upgraded.get("ix_mediaserveritem_tmdbid_item_type") == (
("tmdbid", "item_type"), False,
)
assert "ix_mediaserveritem_tmdbid" not in upgraded
assert "ix_mediaserveritem_id" not in upgraded
migration.downgrade()
downgraded = _index_signatures(connection, "mediaserveritem")
assert "ix_mediaserveritem_tmdbid_item_type" not in downgraded
assert downgraded.get("ix_mediaserveritem_tmdbid") == (
("tmdbid",), False,
)
assert downgraded.get("ix_mediaserveritem_id") == (("id",), False)
def test_index_migration_skips_only_indexes_with_missing_columns(
monkeypatch,
) -> None:
"""当前 schema 应跳过旧字段索引,同时继续处理其他适用索引。"""
engine = sa.create_engine("sqlite://")
metadata = sa.MetaData()
media_server = sa.Table(
"mediaserveritem",
metadata,
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("media_source", sa.String()),
sa.Column("media_id", sa.String()),
sa.Column("item_type", sa.String()),
)
sa.Index(
"ix_mediaserveritem_media_identity_type",
media_server.c.media_source,
media_server.c.media_id,
media_server.c.item_type,
)
message = sa.Table(
"message",
metadata,
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("reg_time", sa.DateTime()),
)
sa.Index("ix_message_reg_time", message.c.reg_time)
with engine.begin() as connection:
metadata.create_all(connection)
migration = _bind_migration(monkeypatch, connection)
migration.upgrade()
media_indexes = _index_signatures(connection, "mediaserveritem")
message_indexes = _index_signatures(connection, "message")
assert "ix_mediaserveritem_tmdbid_item_type" not in media_indexes
assert media_indexes.get("ix_mediaserveritem_media_identity_type") == (
("media_source", "media_id", "item_type"), False,
)
assert "ix_message_reg_time" not in message_indexes
assert message_indexes.get("ix_message_reg_time_id") == (
("reg_time", "id"), False,
)
migration.downgrade()
media_indexes = _index_signatures(connection, "mediaserveritem")
message_indexes = _index_signatures(connection, "message")
assert "ix_mediaserveritem_tmdbid" not in media_indexes
assert media_indexes.get("ix_mediaserveritem_media_identity_type") == (
("media_source", "media_id", "item_type"), False,
)
assert message_indexes.get("ix_message_reg_time") == (
("reg_time",), False,
)
assert "ix_message_reg_time_id" not in message_indexes
def test_current_schema_reaches_current_alembic_head(tmp_path: Path) -> None:
"""真实 fresh 启动链应到动态解析的唯一 head且重复升级保持幂等。"""
repository = Path(__file__).resolve().parents[1]
environment = os.environ.copy()
environment.update({
"CONFIG_DIR": str(tmp_path),
"DB_TYPE": "sqlite",
"SUPERUSER": "migration-test-admin",
"SUPERUSER_PASSWORD": "MigrationTestPassword123",
})
_run_current_schema_chain(repository, environment)
def test_current_schema_reaches_current_alembic_head_on_postgresql(
tmp_path: Path,
) -> None:
"""PostgreSQL fresh schema 应到唯一 head不能被吞异常伪装成成功。"""
prefix = "MOVIEPILOT_TEST_POSTGRESQL_"
host = os.getenv(f"{prefix}HOST")
database = os.getenv(f"{prefix}DATABASE")
username = os.getenv(f"{prefix}USERNAME")
if not host or not database or not username:
pytest.skip("未配置隔离 PostgreSQL migration 测试库")
port = os.getenv(f"{prefix}PORT", "5432")
password = os.getenv(f"{prefix}PASSWORD", "")
schema = f"p1_db1_{uuid.uuid4().hex}"
with psycopg2.connect(
host=host,
port=port,
dbname=database,
user=username,
password=password,
) as connection:
connection.autocommit = True
with connection.cursor() as cursor:
cursor.execute(
sql.SQL("CREATE SCHEMA {}").format(sql.Identifier(schema))
)
repository = Path(__file__).resolve().parents[1]
environment = os.environ.copy()
environment.update({
"CONFIG_DIR": str(tmp_path),
"DB_TYPE": "postgresql",
"DB_POSTGRESQL_HOST": host,
"DB_POSTGRESQL_PORT": port,
"DB_POSTGRESQL_DATABASE": database,
"DB_POSTGRESQL_USERNAME": username,
"DB_POSTGRESQL_PASSWORD": password,
"PGOPTIONS": f"-c search_path={schema}",
"SUPERUSER": "migration-test-admin",
"SUPERUSER_PASSWORD": "MigrationTestPassword123",
})
try:
_run_current_schema_chain(repository, environment)
finally:
with psycopg2.connect(
host=host,
port=port,
dbname=database,
user=username,
password=password,
) as connection:
connection.autocommit = True
with connection.cursor() as cursor:
cursor.execute(
sql.SQL("DROP SCHEMA IF EXISTS {} CASCADE").format(
sql.Identifier(schema)
)
)

View File

@@ -0,0 +1,683 @@
import importlib
import os
import uuid
import psycopg2
from psycopg2 import sql
import pytest
import sqlalchemy as sa
from alembic.migration import MigrationContext
from alembic.operations import Operations
E6_MIGRATION = "database.versions.e6a1c4b8d2f0_2_2_13"
F7_MIGRATION = "database.versions.f7b2d5c9a301_2_2_14"
IDENTITY_COLUMN_TYPES = {
"mediaid": sa.String,
"tmdbid": sa.Integer,
"doubanid": sa.String,
"bangumiid": sa.Integer,
"anilistid": sa.Integer,
"media_source": sa.String,
"media_id": sa.String,
}
F7_INDEX_SIGNATURES = {
"subscribe": {
"ix_subscribe_anilistid": (("anilistid",), False),
"ix_subscribe_media_source": (("media_source",), False),
"ix_subscribe_media_id": (("media_id",), False),
"ix_subscribe_media_identity": (
("media_source", "media_id"), False,
),
},
"subscribehistory": {
"ix_subscribehistory_anilistid": (("anilistid",), False),
"ix_subscribehistory_media_source": (("media_source",), False),
"ix_subscribehistory_media_id": (("media_id",), False),
"ix_subscribehistory_media_identity": (
("media_source", "media_id"), False,
),
},
"downloadhistory": {
"ix_downloadhistory_bangumiid": (("bangumiid",), False),
"ix_downloadhistory_anilistid": (("anilistid",), False),
"ix_downloadhistory_media_source": (("media_source",), False),
"ix_downloadhistory_media_id": (("media_id",), False),
"ix_downloadhistory_media_identity": (
("media_source", "media_id"), False,
),
},
"transferhistory": {
"ix_transferhistory_bangumiid": (("bangumiid",), False),
"ix_transferhistory_anilistid": (("anilistid",), False),
"ix_transferhistory_media_identity": (
("media_source", "media_id"), False,
),
},
"downloadfailure": {
"ix_downloadfailure_media_identity_site": (
("type", "media_source", "media_id", "site"), False,
),
},
}
def _bind_migration(monkeypatch, connection, module_name: str):
"""把历史 revision 绑定到当前 disposable connection。"""
migration = importlib.import_module(module_name)
context = MigrationContext.configure(connection)
monkeypatch.setattr(migration, "op", Operations(context))
return migration
def _column_names(connection, table_name: str) -> set[str]:
"""返回指定测试表的字段集合。"""
return {
column["name"]
for column in sa.inspect(connection).get_columns(table_name)
}
def _index_signatures(
connection,
table_name: str,
) -> dict[str, tuple[tuple[str, ...], bool]]:
"""返回索引名称到字段顺序及唯一性的映射。"""
return {
index["name"]: (
tuple(index.get("column_names") or ()),
bool(index.get("unique")),
)
for index in sa.inspect(connection).get_indexes(table_name)
}
def _assert_index_signatures(
actual: dict[str, tuple[tuple[str, ...], bool]],
expected: dict[str, tuple[tuple[str, ...], bool]],
) -> None:
"""断言关键索引的字段顺序和唯一性均符合迁移契约。"""
for index_name, signature in expected.items():
assert actual.get(index_name) == signature, (index_name, actual)
def _rows(connection, table_name: str) -> list[dict]:
"""按主键读取迁移后的测试数据。"""
table = sa.Table(table_name, sa.MetaData(), autoload_with=connection)
return list(
connection.execute(sa.select(table).order_by(table.c.id)).mappings()
)
def _create_transferhistory(
connection,
columns: tuple[str, ...],
indexes: tuple[tuple[str, tuple[str, ...]], ...] = (),
) -> sa.Table:
"""创建具有指定兼容字段和索引的最小整理历史表。"""
metadata = sa.MetaData()
table = sa.Table(
"transferhistory",
metadata,
sa.Column("id", sa.Integer(), primary_key=True),
*(
sa.Column(name, IDENTITY_COLUMN_TYPES[name]())
for name in columns
),
)
for name, fields in indexes:
sa.Index(name, *(table.c[field] for field in fields))
metadata.create_all(connection)
return table
def test_e6_backfill_preserves_priority_existing_identity_and_indexes(
monkeypatch,
) -> None:
"""e6 应保持 TMDB 优先级、豆瓣兜底和已有规范身份。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
table = _create_transferhistory(
connection,
("tmdbid", "doubanid", "media_source", "media_id"),
(("ix_transferhistory_media_source", ("media_source",)),),
)
connection.execute(table.insert(), [
{
"id": 1, "tmdbid": 123, "doubanid": "ignored",
"media_source": None, "media_id": None,
},
{
"id": 2, "tmdbid": None, "doubanid": "456",
"media_source": None, "media_id": None,
},
{
"id": 3,
"tmdbid": 789,
"doubanid": "also-ignored",
"media_source": "plugin_source",
"media_id": "custom-1",
},
])
migration = _bind_migration(monkeypatch, connection, E6_MIGRATION)
migration.upgrade()
migration.upgrade()
migrated_rows = _rows(connection, "transferhistory")
indexes = _index_signatures(connection, "transferhistory")
assert (migrated_rows[0]["media_source"], migrated_rows[0]["media_id"]) == (
"themoviedb", "123",
)
assert (migrated_rows[1]["media_source"], migrated_rows[1]["media_id"]) == (
"douban", "456",
)
assert (migrated_rows[2]["media_source"], migrated_rows[2]["media_id"]) == (
"plugin_source", "custom-1",
)
_assert_index_signatures(indexes, {
"ix_transferhistory_media_source": (("media_source",), False),
"ix_transferhistory_media_id": (("media_id",), False),
})
@pytest.mark.parametrize(
("legacy_columns", "values", "expected_identity"),
(
(("tmdbid",), {"tmdbid": 123}, ("themoviedb", "123")),
(("doubanid",), {"doubanid": "456"}, ("douban", "456")),
((), {}, (None, None)),
),
)
def test_e6_skips_only_missing_legacy_sources(
monkeypatch,
legacy_columns,
values,
expected_identity,
) -> None:
"""e6 应仅执行物理存在的旧来源字段回填。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
table = _create_transferhistory(connection, legacy_columns)
connection.execute(table.insert(), {"id": 1, **values})
migration = _bind_migration(monkeypatch, connection, E6_MIGRATION)
migration.upgrade()
migration.upgrade()
migrated = _rows(connection, "transferhistory")[0]
assert (migrated["media_source"], migrated["media_id"]) == expected_identity
def test_e6_retry_completes_interrupted_column_and_index_state(
monkeypatch,
) -> None:
"""e6 重试应独立补齐缺失的 revision-owned 字段和索引。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
table = _create_transferhistory(
connection,
("tmdbid", "doubanid", "media_source"),
(("ix_transferhistory_media_source", ("media_source",)),),
)
connection.execute(
table.insert(),
{"id": 1, "tmdbid": None, "doubanid": "456"},
)
migration = _bind_migration(monkeypatch, connection, E6_MIGRATION)
migration.upgrade()
migration.upgrade()
columns = _column_names(connection, "transferhistory")
indexes = _index_signatures(connection, "transferhistory")
migrated = _rows(connection, "transferhistory")[0]
assert {"media_source", "media_id"}.issubset(columns)
_assert_index_signatures(indexes, {
"ix_transferhistory_media_source": (("media_source",), False),
"ix_transferhistory_media_id": (("media_id",), False),
})
assert (migrated["media_source"], migrated["media_id"]) == (
"douban", "456",
)
def _create_f7_tables(
connection,
layouts: dict[str, tuple[str, ...]],
indexes: dict[str, tuple[tuple[str, tuple[str, ...]], ...]] | None = None,
transferhistory_identity: bool = True,
) -> dict[str, sa.Table]:
"""创建五张具有不同历史字段状态的最小媒体表。"""
metadata = sa.MetaData()
indexes = indexes or {}
transferhistory_columns = (
("media_source", "media_id") if transferhistory_identity else ()
)
base_columns = {
"subscribe": (),
"subscribehistory": (),
"downloadhistory": (),
"transferhistory": transferhistory_columns,
"downloadfailure": (),
}
tables = {}
for table_name, required_columns in base_columns.items():
column_names = tuple(dict.fromkeys(required_columns + layouts.get(table_name, ())))
columns = [sa.Column("id", sa.Integer(), primary_key=True)]
if table_name == "downloadfailure":
columns.extend((
sa.Column("type", sa.String()),
sa.Column("site", sa.Integer()),
))
columns.extend(
sa.Column(name, IDENTITY_COLUMN_TYPES[name]())
for name in column_names
)
table = sa.Table(table_name, metadata, *columns)
for index_name, fields in indexes.get(table_name, ()):
sa.Index(index_name, *(table.c[field] for field in fields))
tables[table_name] = table
metadata.create_all(connection)
return tables
def test_f7_backfill_uses_physical_columns_and_preserves_priority(
monkeypatch,
) -> None:
"""f7 应先用带前缀 ID再按四类来源字段顺序回填。"""
engine = sa.create_engine("sqlite://")
layouts = {
"subscribe": (
"mediaid", "tmdbid", "doubanid", "bangumiid", "anilistid",
"media_source", "media_id",
),
"subscribehistory": ("mediaid",),
"downloadhistory": ("doubanid",),
"transferhistory": ("tmdbid",),
"downloadfailure": (),
}
with engine.begin() as connection:
tables = _create_f7_tables(connection, layouts)
connection.execute(tables["subscribe"].insert(), [
{
"id": 1, "mediaid": "douban:prefix-1", "tmdbid": 101,
"doubanid": "201", "bangumiid": 301, "anilistid": 401,
"media_source": None, "media_id": None,
},
{
"id": 2, "mediaid": None, "tmdbid": 102, "doubanid": "202",
"bangumiid": 302, "anilistid": 402,
"media_source": None, "media_id": None,
},
{
"id": 3, "mediaid": None, "tmdbid": None,
"doubanid": "203", "bangumiid": 303,
"anilistid": 403,
"media_source": None, "media_id": None,
},
{
"id": 4, "mediaid": None, "tmdbid": None,
"doubanid": None, "bangumiid": 304, "anilistid": 404,
"media_source": None, "media_id": None,
},
{
"id": 5, "mediaid": None, "tmdbid": None,
"doubanid": None, "bangumiid": None, "anilistid": 405,
"media_source": None, "media_id": None,
},
{
"id": 6, "mediaid": "tmdb:106", "tmdbid": 106,
"doubanid": None, "bangumiid": None, "anilistid": None,
"media_source": "plugin_source", "media_id": "custom-6",
},
])
connection.execute(
tables["subscribehistory"].insert(),
{"id": 1, "mediaid": "anilist:154587"},
)
connection.execute(
tables["downloadhistory"].insert(),
{"id": 1, "doubanid": "35209731"},
)
connection.execute(
tables["transferhistory"].insert(),
{"id": 1, "tmdbid": 209867},
)
connection.execute(tables["downloadfailure"].insert(), {
"id": 1, "type": "电视剧", "site": 1,
})
migration = _bind_migration(monkeypatch, connection, F7_MIGRATION)
migration.upgrade()
migration.upgrade()
subscribe_rows = _rows(connection, "subscribe")
partial_rows = {
name: _rows(connection, name)[0]
for name in (
"subscribehistory", "downloadhistory", "transferhistory",
"downloadfailure",
)
}
assert [
(row["media_source"], row["media_id"])
for row in subscribe_rows
] == [
("douban", "prefix-1"),
("themoviedb", "102"),
("douban", "203"),
("bangumi", "304"),
("anilist", "405"),
("plugin_source", "custom-6"),
]
assert (
partial_rows["subscribehistory"]["media_source"],
partial_rows["subscribehistory"]["media_id"],
) == ("anilist", "154587")
assert (
partial_rows["downloadhistory"]["media_source"],
partial_rows["downloadhistory"]["media_id"],
) == ("douban", "35209731")
assert (
partial_rows["transferhistory"]["media_source"],
partial_rows["transferhistory"]["media_id"],
) == ("themoviedb", "209867")
assert partial_rows["downloadfailure"]["media_id"] is None
def test_f7_retry_completes_mixed_partial_table_states(monkeypatch) -> None:
"""f7 重试应跨表补齐混合缺失的字段和 revision-owned 索引。"""
engine = sa.create_engine("sqlite://")
layouts = {
"subscribe": (
"mediaid", "tmdbid", "anilistid", "media_source", "media_id",
),
"subscribehistory": ("mediaid", "doubanid", "media_source"),
"downloadhistory": ("bangumiid", "media_id"),
"transferhistory": ("tmdbid", "bangumiid"),
"downloadfailure": ("anilistid", "media_source", "media_id"),
}
existing_indexes = {
"subscribe": (("ix_subscribe_anilistid", ("anilistid",)),),
"subscribehistory": (
("ix_subscribehistory_media_source", ("media_source",)),
),
"downloadhistory": (
("ix_downloadhistory_bangumiid", ("bangumiid",)),
),
"transferhistory": (
(
"ix_transferhistory_media_identity",
("media_source", "media_id"),
),
),
"downloadfailure": (
(
"ix_downloadfailure_media_identity_site",
("type", "media_source", "media_id", "site"),
),
),
}
with engine.begin() as connection:
tables = _create_f7_tables(connection, layouts, existing_indexes)
connection.execute(tables["subscribe"].insert(), {
"id": 1,
"mediaid": "douban:88",
"tmdbid": 11,
"media_source": "plugin_source",
"media_id": "preserved-1",
})
connection.execute(tables["subscribehistory"].insert(), {
"id": 1, "mediaid": "tmdb:22", "doubanid": "33",
})
connection.execute(tables["downloadhistory"].insert(), [
{"id": 1, "bangumiid": 44, "media_id": None},
{"id": 2, "bangumiid": 45, "media_id": "preserved-2"},
])
connection.execute(
tables["transferhistory"].insert(),
{"id": 1, "tmdbid": 55, "bangumiid": 56},
)
connection.execute(tables["downloadfailure"].insert(), {
"id": 1, "type": "动画", "site": 1, "anilistid": 66,
})
migration = _bind_migration(monkeypatch, connection, F7_MIGRATION)
migration.upgrade()
migration.upgrade()
columns = {
name: _column_names(connection, name)
for name in tables
}
indexes = {
name: _index_signatures(connection, name)
for name in tables
}
rows = {name: _rows(connection, name) for name in tables}
assert {"anilistid", "media_source", "media_id"}.issubset(columns["subscribe"])
assert {"anilistid", "media_source", "media_id"}.issubset(
columns["subscribehistory"]
)
assert {
"bangumiid", "anilistid", "media_source", "media_id",
}.issubset(columns["downloadhistory"])
assert {"bangumiid", "anilistid"}.issubset(columns["transferhistory"])
assert {
"bangumiid", "anilistid", "media_source", "media_id",
}.issubset(columns["downloadfailure"])
for table_name, expected in F7_INDEX_SIGNATURES.items():
_assert_index_signatures(indexes[table_name], expected)
assert (rows["subscribe"][0]["media_source"], rows["subscribe"][0]["media_id"]) == (
"plugin_source", "preserved-1",
)
assert (
rows["subscribehistory"][0]["media_source"],
rows["subscribehistory"][0]["media_id"],
) == ("themoviedb", "22")
assert (
rows["downloadhistory"][0]["media_source"],
rows["downloadhistory"][0]["media_id"],
) == ("bangumi", "44")
assert rows["downloadhistory"][1]["media_source"] is None
assert rows["downloadhistory"][1]["media_id"] == "preserved-2"
assert (
rows["transferhistory"][0]["media_source"],
rows["transferhistory"][0]["media_id"],
) == ("themoviedb", "55")
assert (
rows["downloadfailure"][0]["media_source"],
rows["downloadfailure"][0]["media_id"],
) == ("anilist", "66")
def _exercise_e6_f7_round_trip(monkeypatch, engine: sa.Engine) -> None:
"""验证两个历史 revision 可升级、降级并再次升级。"""
layouts = {
"subscribe": ("mediaid", "tmdbid", "doubanid", "bangumiid"),
"subscribehistory": ("mediaid", "tmdbid", "doubanid", "bangumiid"),
"downloadhistory": ("tmdbid", "doubanid"),
"transferhistory": ("tmdbid", "doubanid"),
"downloadfailure": ("tmdbid", "doubanid"),
}
with engine.begin() as connection:
tables = _create_f7_tables(
connection,
layouts,
transferhistory_identity=False,
)
connection.execute(tables["subscribe"].insert(), {
"id": 1, "mediaid": "douban:88", "tmdbid": 11,
})
connection.execute(tables["subscribehistory"].insert(), {
"id": 1, "mediaid": None, "tmdbid": 22,
})
connection.execute(tables["downloadhistory"].insert(), {
"id": 1, "tmdbid": None, "doubanid": "33",
})
connection.execute(tables["transferhistory"].insert(), {
"id": 1, "tmdbid": 55, "doubanid": "ignored",
})
connection.execute(tables["downloadfailure"].insert(), {
"id": 1, "type": "动画", "site": 1,
"tmdbid": None, "doubanid": "66",
})
e6_migration = _bind_migration(monkeypatch, connection, E6_MIGRATION)
e6_migration.upgrade()
_assert_index_signatures(
_index_signatures(connection, "transferhistory"),
{
"ix_transferhistory_media_source": (
("media_source",), False,
),
"ix_transferhistory_media_id": (("media_id",), False),
},
)
e6_migration.downgrade()
assert "media_source" not in _column_names(connection, "transferhistory")
assert "media_id" not in _column_names(connection, "transferhistory")
assert "ix_transferhistory_media_source" not in _index_signatures(
connection, "transferhistory"
)
e6_migration.upgrade()
transfer_row = _rows(connection, "transferhistory")[0]
assert (transfer_row["media_source"], transfer_row["media_id"]) == (
"themoviedb", "55",
)
pre_f7_columns = {
"subscribe": {
"id", "mediaid", "tmdbid", "doubanid", "bangumiid",
},
"subscribehistory": {
"id", "mediaid", "tmdbid", "doubanid", "bangumiid",
},
"downloadhistory": {"id", "tmdbid", "doubanid"},
"transferhistory": {
"id", "tmdbid", "doubanid", "media_source", "media_id",
},
"downloadfailure": {
"id", "type", "site", "tmdbid", "doubanid",
},
}
for table_name, expected_columns in pre_f7_columns.items():
assert _column_names(connection, table_name) == expected_columns
f7_migration = _bind_migration(monkeypatch, connection, F7_MIGRATION)
f7_migration.upgrade()
for table_name, expected in F7_INDEX_SIGNATURES.items():
_assert_index_signatures(
_index_signatures(connection, table_name),
expected,
)
f7_migration.downgrade()
for table_name, expected_columns in pre_f7_columns.items():
assert _column_names(connection, table_name) == expected_columns
for table_name, expected in F7_INDEX_SIGNATURES.items():
indexes = _index_signatures(connection, table_name)
assert set(expected).isdisjoint(indexes), (table_name, indexes)
f7_migration.upgrade()
for table_name, expected in F7_INDEX_SIGNATURES.items():
_assert_index_signatures(
_index_signatures(connection, table_name),
expected,
)
rows = {
table_name: _rows(connection, table_name)[0]
for table_name in layouts
}
assert (rows["subscribe"]["media_source"], rows["subscribe"]["media_id"]) == (
"douban", "88",
)
assert (
rows["subscribehistory"]["media_source"],
rows["subscribehistory"]["media_id"],
) == ("themoviedb", "22")
assert (
rows["downloadhistory"]["media_source"],
rows["downloadhistory"]["media_id"],
) == ("douban", "33")
assert (
rows["transferhistory"]["media_source"],
rows["transferhistory"]["media_id"],
) == ("themoviedb", "55")
assert (
rows["downloadfailure"]["media_source"],
rows["downloadfailure"]["media_id"],
) == ("douban", "66")
def test_e6_f7_round_trip_on_sqlite(monkeypatch) -> None:
"""SQLite 应支持两个 revision 的 upgrade/downgrade/re-upgrade。"""
_exercise_e6_f7_round_trip(monkeypatch, sa.create_engine("sqlite://"))
def test_e6_f7_round_trip_on_postgresql(monkeypatch) -> None:
"""已配置的 PostgreSQL 15 应执行与 SQLite 相同的往返路径。"""
prefix = "MOVIEPILOT_TEST_POSTGRESQL_"
host = os.getenv(f"{prefix}HOST")
database = os.getenv(f"{prefix}DATABASE")
username = os.getenv(f"{prefix}USERNAME")
if not host or not database or not username:
pytest.skip("未配置隔离 PostgreSQL migration 测试库")
port = os.getenv(f"{prefix}PORT", "5432")
password = os.getenv(f"{prefix}PASSWORD", "")
schema = f"p1_db1_roundtrip_{uuid.uuid4().hex}"
with psycopg2.connect(
host=host,
port=port,
dbname=database,
user=username,
password=password,
) as connection:
assert connection.server_version // 10000 == 15, connection.server_version
connection.autocommit = True
with connection.cursor() as cursor:
cursor.execute(
sql.SQL("CREATE SCHEMA {}").format(sql.Identifier(schema))
)
engine = None
try:
engine = sa.create_engine(
sa.URL.create(
"postgresql+psycopg2",
username=username,
password=password,
host=host,
port=int(port),
database=database,
),
connect_args={"options": f"-csearch_path={schema}"},
)
_exercise_e6_f7_round_trip(monkeypatch, engine)
finally:
if engine is not None:
engine.dispose()
with psycopg2.connect(
host=host,
port=port,
dbname=database,
user=username,
password=password,
) as connection:
connection.autocommit = True
with connection.cursor() as cursor:
cursor.execute(
sql.SQL("DROP SCHEMA IF EXISTS {} CASCADE").format(
sql.Identifier(schema)
)
)