mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/jxxghp/MoviePilot.git
synced 2026-09-20 08:03:34 +08:00
插件配置此前寄存在 systemconfig 的 plugin.<实例ID> 单键下,实例描述符则整份挤在
PluginInstances 这一个 JSON 键里,两者都不成立:
- plugin.<ID> 是裸字符串 key,而仓库规则本身禁止用裸字符串做 SystemConfig key,
只允许先定义 SystemConfigKey 枚举项;插件 ID 由用户安装决定,永远枚举不出常量。
条目数随安装量增长,混在系统设置表里会把主程序自己的设置项淹掉。
- 那个键存的其实是实例 ID,表里没有任何一列说得出它属于哪个插件,想列出某个插件
的全部实例配置只能靠字符串前缀去猜。
- 实例描述符整份读出再整份写回,改一个分身要重写全部分身。
改为 plugininstance 一实例一行:instance_id 与 source_plugin_id 构成身份,本体与
分身由两者是否相等派生而不设模式列——模式列会是这个等式的冗余副本,两者一旦失步
同一行就会在不同读取口被判成不同角色。展示信息与业务参数同存一行,属于同一个
生命周期,分表只会让建分身、删分身退化成两张表之间的协调问题。
读写路由落在 SystemConfigOper.get/set/delete,而不是在各个调用方各改一处:第三方
插件可能直接用 self.systemconfig.get("plugin.xxx") 读写自己的配置,只改
PluginConfigStore 与 _PluginBase.get_config/update_config 必然漏掉它们。路由只做
前缀识别,插入、更新与空本体行回收都委托 PluginInstanceOper,不在配置层重抄一遍。
PluginInstances 旧键迁移后不删,留作回滚依据,并以
SystemConfigKey.PluginInstancesImported 标志防止重复导入;判据不能是「表当前为空」,
否则用户把分身全部删光后,下次启动会把它们整批导回来。
迁移链:b2d4f6a8c1e3 -> 281965691a20(3.0.34 建表并搬描述符)
-> c4e1a7b9d2f6(3.0.35 加 config_data 并搬 plugin.* 配置)。
依赖基线与启动模块数随两个新模块重算,架构文档与数据库技能表目录同步登记新表。
220 lines
8.3 KiB
Python
220 lines
8.3 KiB
Python
"""插件实例描述符表 Alembic 迁移测试。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
|
|
import sqlalchemy as sa
|
|
from alembic.migration import MigrationContext
|
|
from alembic.operations import Operations
|
|
|
|
from app.db.models.systemconfig import SystemConfig
|
|
|
|
MIGRATION_MODULE = "database.versions.281965691a20_3_0_34"
|
|
|
|
# 本迁移建出的列集合;业务参数列由后续 c4e1a7b9d2f6 迁移补上,这里不与当前完整模型
|
|
# 比较,否则每次给该表加列都要回头改这条断言。
|
|
EXPECTED_COLUMNS = {
|
|
"id",
|
|
"instance_id",
|
|
"source_plugin_id",
|
|
"plugin_name",
|
|
"plugin_desc",
|
|
"plugin_icon",
|
|
"created_at",
|
|
"updated_at",
|
|
}
|
|
|
|
|
|
def _bind_migration(monkeypatch, connection):
|
|
"""把迁移绑定到隔离数据库连接。"""
|
|
migration = importlib.import_module(MIGRATION_MODULE)
|
|
context = MigrationContext.configure(connection)
|
|
monkeypatch.setattr(migration, "op", Operations(context))
|
|
return migration
|
|
|
|
|
|
def _seed_legacy_key(connection: sa.engine.Connection, value) -> None:
|
|
"""写入旧 systemconfig 单键,模拟迁移前的实例描述存量数据。"""
|
|
connection.execute(
|
|
sa.insert(SystemConfig.__table__).values(key="PluginInstances", value=value)
|
|
)
|
|
|
|
|
|
def test_plugin_instance_migration_migrates_legacy_dict_payload_and_keeps_source_key(
|
|
monkeypatch,
|
|
) -> None:
|
|
"""字典载荷应逐条搬入新表,原 systemconfig 键保留不删,且可重复升级与完整回滚。"""
|
|
engine = sa.create_engine("sqlite://")
|
|
with engine.begin() as connection:
|
|
SystemConfig.__table__.create(connection)
|
|
_seed_legacy_key(
|
|
connection,
|
|
{
|
|
"DemoPluginWork": {
|
|
"instance_id": "DemoPluginWork",
|
|
"source_plugin_id": "DemoPlugin",
|
|
"plugin_name": "工作实例",
|
|
"plugin_icon": "work.svg",
|
|
},
|
|
},
|
|
)
|
|
migration = _bind_migration(monkeypatch, connection)
|
|
|
|
migration.upgrade()
|
|
migration.upgrade()
|
|
|
|
inspector = sa.inspect(connection)
|
|
assert "plugininstance" in inspector.get_table_names()
|
|
columns = {column["name"] for column in inspector.get_columns("plugininstance")}
|
|
assert columns == EXPECTED_COLUMNS
|
|
unique_constraints = {
|
|
constraint["name"]: tuple(constraint["column_names"])
|
|
for constraint in inspector.get_unique_constraints("plugininstance")
|
|
}
|
|
assert unique_constraints["uq_plugininstance_instance_id"] == ("instance_id",)
|
|
indexes = {index["name"] for index in inspector.get_indexes("plugininstance")}
|
|
assert "ix_plugininstance_source_plugin_id" in indexes
|
|
|
|
table = sa.Table("plugininstance", sa.MetaData(), autoload_with=connection)
|
|
rows = connection.execute(sa.select(table)).mappings().all()
|
|
assert len(rows) == 1
|
|
row = rows[0]
|
|
assert row["instance_id"] == "DemoPluginWork"
|
|
assert row["source_plugin_id"] == "DemoPlugin"
|
|
assert row["plugin_name"] == "工作实例"
|
|
assert row["plugin_icon"] == "work.svg"
|
|
|
|
legacy_row = connection.execute(
|
|
sa.select(SystemConfig.value).where(SystemConfig.key == "PluginInstances")
|
|
).scalar_one()
|
|
assert legacy_row == {
|
|
"DemoPluginWork": {
|
|
"instance_id": "DemoPluginWork",
|
|
"source_plugin_id": "DemoPlugin",
|
|
"plugin_name": "工作实例",
|
|
"plugin_icon": "work.svg",
|
|
},
|
|
}
|
|
|
|
migration.downgrade()
|
|
assert "plugininstance" not in sa.inspect(connection).get_table_names()
|
|
assert connection.execute(
|
|
sa.select(SystemConfig.value).where(SystemConfig.key == "PluginInstances")
|
|
).scalar_one() == legacy_row
|
|
|
|
migration.upgrade()
|
|
restored = sa.Table("plugininstance", sa.MetaData(), autoload_with=connection)
|
|
restored_rows = connection.execute(sa.select(restored)).mappings().all()
|
|
assert len(restored_rows) == 1
|
|
assert restored_rows[0]["instance_id"] == "DemoPluginWork"
|
|
|
|
|
|
def test_plugin_instance_migration_drops_the_redundant_mode_field(monkeypatch) -> None:
|
|
"""旧载荷里的 mode 是身份等式的冗余副本,新表不再为它建列。"""
|
|
engine = sa.create_engine("sqlite://")
|
|
with engine.begin() as connection:
|
|
SystemConfig.__table__.create(connection)
|
|
_seed_legacy_key(
|
|
connection,
|
|
[
|
|
{
|
|
"instance_id": "DemoPluginWork",
|
|
"source_plugin_id": "DemoPlugin",
|
|
"mode": "virtual",
|
|
},
|
|
],
|
|
)
|
|
migration = _bind_migration(monkeypatch, connection)
|
|
migration.upgrade()
|
|
|
|
columns = {
|
|
column["name"]
|
|
for column in sa.inspect(connection).get_columns("plugininstance")
|
|
}
|
|
assert "mode" not in columns
|
|
check_constraints = {
|
|
constraint["name"]
|
|
for constraint in sa.inspect(connection).get_check_constraints("plugininstance")
|
|
}
|
|
assert "ck_plugininstance_mode" not in check_constraints
|
|
|
|
|
|
def test_plugin_instance_migration_migrates_legacy_list_payload(monkeypatch) -> None:
|
|
"""历史列表载荷同样应逐条搬入新表。"""
|
|
engine = sa.create_engine("sqlite://")
|
|
with engine.begin() as connection:
|
|
SystemConfig.__table__.create(connection)
|
|
_seed_legacy_key(
|
|
connection,
|
|
[
|
|
{"instance_id": "DemoPluginWork", "source_plugin_id": "DemoPlugin"},
|
|
{"instance_id": "DemoPluginBackup", "source_plugin_id": "DemoPlugin"},
|
|
],
|
|
)
|
|
migration = _bind_migration(monkeypatch, connection)
|
|
migration.upgrade()
|
|
|
|
table = sa.Table("plugininstance", sa.MetaData(), autoload_with=connection)
|
|
instance_ids = {
|
|
row["instance_id"]
|
|
for row in connection.execute(sa.select(table)).mappings().all()
|
|
}
|
|
assert instance_ids == {"DemoPluginWork", "DemoPluginBackup"}
|
|
|
|
|
|
def test_plugin_instance_migration_skips_malformed_legacy_entries(monkeypatch) -> None:
|
|
"""缺失必填字段或非字典条目必须被跳过,不得中断迁移。"""
|
|
engine = sa.create_engine("sqlite://")
|
|
with engine.begin() as connection:
|
|
SystemConfig.__table__.create(connection)
|
|
_seed_legacy_key(
|
|
connection,
|
|
[
|
|
{"instance_id": "MissingSource"},
|
|
{"source_plugin_id": "DemoPlugin"},
|
|
"not-a-dict",
|
|
{"instance_id": "DemoPluginWork", "source_plugin_id": "DemoPlugin"},
|
|
],
|
|
)
|
|
migration = _bind_migration(monkeypatch, connection)
|
|
migration.upgrade()
|
|
|
|
table = sa.Table("plugininstance", sa.MetaData(), autoload_with=connection)
|
|
rows = connection.execute(sa.select(table)).mappings().all()
|
|
assert [row["instance_id"] for row in rows] == ["DemoPluginWork"]
|
|
|
|
|
|
def test_plugin_instance_migration_without_legacy_key_creates_empty_table(
|
|
monkeypatch,
|
|
) -> None:
|
|
"""旧键缺失或为空时应正常建表,不产生任何数据行。"""
|
|
engine = sa.create_engine("sqlite://")
|
|
with engine.begin() as connection:
|
|
SystemConfig.__table__.create(connection)
|
|
migration = _bind_migration(monkeypatch, connection)
|
|
migration.upgrade()
|
|
|
|
assert "plugininstance" in sa.inspect(connection).get_table_names()
|
|
table = sa.Table("plugininstance", sa.MetaData(), autoload_with=connection)
|
|
assert connection.execute(sa.select(table)).mappings().all() == []
|
|
|
|
|
|
def test_plugin_instance_migration_accepts_fresh_current_schema(monkeypatch) -> None:
|
|
"""create_all 已建当前表时重复升级不得创建冲突对象。"""
|
|
from app.db.models.plugininstance import PluginInstance
|
|
|
|
engine = sa.create_engine("sqlite://")
|
|
with engine.begin() as connection:
|
|
SystemConfig.__table__.create(connection)
|
|
PluginInstance.__table__.create(connection)
|
|
migration = _bind_migration(monkeypatch, connection)
|
|
|
|
migration.upgrade()
|
|
migration.upgrade()
|
|
|
|
assert {
|
|
column["name"]
|
|
for column in sa.inspect(connection).get_columns("plugininstance")
|
|
} == {column.name for column in PluginInstance.__table__.columns}
|